# Property Match (public buyer quiz → linked lead)

**Portals:** Main (public quiz) + Manage (person workbench) · **Namespace:** `Src\PropertyMatch` ·
**Routes:** `public.property-match(.submit/.start/.verify/.booked)`,
`manage.property-match.(index/detail/remarks.index/status/assign/remarks.store/tags)`

**Nav:** Sales → **Property Booking** → ① **Pipeline** (`/manage/sales-projects?view=match`), the first
stage of that line's rail (GUIDELINES §15). Its page header carries a **Landing Page** button opening
the public quiz (`/property-match`) in a new tab — a plain `<a>` with a **relative** href, so it opens
on whatever host the admin is already on instead of jumping to production. That Pipeline table
([`PropertyMatchView.vue`](/resources/js/Pages/Manage/SalesProjects/Partials/PropertyMatchView.vue))
carries **Interested in** (what the buyer picked) next to **Match result** (what the engine
recommended) — two columns on purpose, since the gap between them is the follow-up call. Each row
**expands** to the full answer trail (the `q_label`/`a_label` snapshots, sent with the page — no
lazy fetch), so a caller reads what the buyer was actually asked without leaving the pipeline.
Each row also carries the lead's CRM context — **Member** / **Pipelines** / **Property** plus the
banded **ENGAGEMENTS** columns (Message · Zoom Meeting · Zoom Webinar · Phone Call · **Portal**,
the activity-trail count + last touch) — batched per page by the shared `BuildsLeadEngagementStats`
trait (`app/Http/Controllers/Concerns/`), so it shows the same numbers as the Leads index (the
event Registrations tab reuses the identical columns).

A public "2-minute buyer quiz" lead-gen page. A visitor answers up to 4 financing/goal
questions; a hard-coded decision engine matches them to one of 10 unit-type recommendations across
Peel Lane / The Andaman Sunway / Binastra Cochrane / KLCC; they verify **both** email and phone,
which **links a real CRM lead**, then book a Zoom consult via WhatsApp.

## What it does

- **The questions + decision logic are HARD-CODED on the frontend** ([`content.js`](/resources/js/Pages/Main/PropertyMatch/content.js)) —
  bilingual (en/zh), a 1:1 port of the reference project's `PropertyMatchData`. The backend stores
  only a **self-describing snapshot** of what was asked/answered/shown, so changing a question or the
  copy needs **zero schema change**.
- **The buyer also declares their OWN interest.** After the 4 financing questions and **before**
  verification, a final question asks *which property are you interested in* — a fixed option list
  (Peel Lane / The Andaman Sunway / Binastra Cochrane / KLCC Business Suite / Not sure yet), never
  free text. It is deliberately **not** an input to `decideKey()`: `match_key` stays what the ENGINE
  recommends, `interest_property` is what the BUYER said they want, so sales can read the two side by
  side (agreement, or a gap worth a call). The whitelist is
  [`PropertyMatchSubmission::INTERESTS`](/src/PropertyMatch/PropertyMatchSubmission.php) and the
  request rejects anything else — the option copy is bilingual in `content.js` (`PM_INTERESTS`),
  whose `key` values MUST stay identical to those constants.
- **The pre-filled WhatsApp message puts that gap where it can be seen** (`waLink()`, 2026-08-10):
  **`Interested in:` and `Recommended:` as two adjacent lines.** Before this the interest was in the
  message but buried as the last link of a five-item answer chain, so the consultant had to read the
  whole chain to find the one fact that decides how the call opens. It is now dropped from that chain
  (filtered on `q_key`, never on position) and stated once, on its own line. Five possible values —
  the four projects, plus *"Still exploring — happy to go with the recommendation"* for the `unsure`
  option, worded that way because *"I'm not sure yet"* tells a consultant only that the buyer has no
  view, while this tells them what to do about it.
  - ⚠️ **Those two lines are ENGLISH IN BOTH FLOWS, values included**
    ([`PM_WA_FIXED`](/resources/js/Pages/Main/PropertyMatch/content.js)) — the rest of the message
    follows the buyer's language. The consultant reads these two dozens of times a week across buyers
    who took the quiz in either language, so the same words in the same order means they are found at
    a glance instead of parsed. The buyer loses almost nothing: all four project names are proper
    nouns that were already identical in both.
    - ⚠️ **The recommendation's UNIT is forced English too** (`PM_R[key].en.unit`, not the buyer's
      language). **8 of the 10 units are translated** — `3-Bedroom · Dual Key` / `3房 · Dual Key` —
      so fixing only the label would have left the line varying anyway, and half a fixed line is
      worth nothing.
    - ⚠️ `PM_WA_FIXED` lives **outside `PM_T`**, and that placement is load-bearing. Everything inside
      `PM_T` has an `en` twin and a `zh` twin, so English strings sitting in the `zh` block read as an
      untranslated leftover and the next person to tidy the file would translate them in good faith.
      Outside it there is no `zh` slot to fill, so it cannot drift back.
  - ⚠️ **The two lines are STATED, never compared.** A first cut also appended *"different from what I
    picked…"* whenever the engine disagreed with the buyer. It was removed the same day: this is the
    BUYER's message, sent from the buyer's own WhatsApp, and a sentence putting a disagreement in
    their mouth that they never raised is not theirs to send. Both facts are on screen; the consultant
    draws the conclusion.
  - Everything else in the message **is** bilingual (`waIntro` / `waAnswers` / `waName` / … in both
    `PM_T` blocks). The BUYER reads it in WhatsApp before pressing send: an all-English block in the
    middle of a 中文 flow reads as a bug, and a message somebody cannot read is one they edit or
    delete — taking the answers with it.
- **Verify before reveal.** Flow: `intro → contact → 4 questions → interest → submit → VERIFY → result → book → done`.
  The answers are saved at **submit** (before verification, so an abandoned quiz still captures its
  answers); the email+phone OTP verification then **links the lead** and the result is revealed.
  Verification sits before the reveal so a linked lead is always a *proven* one.
- **Same identity matrix as `/register`, but never signs in.** A verified submission runs through
  [`PasswordlessAuth::completeRegistration`](/app/Services/Auth/PasswordlessAuth.php) — the SAME
  dual-key matrix `/register` uses: create one account / **AUTO-MERGE** two proven accounts (richer
  survives) / supersede a differing stored key / hold a paying-member-on-unverified-key or privileged
  pair for admin review. All its guards carry over (a **staff** account is never auto-merged; the
  member-on-unverified-key guard holds; login is stamped only for a genuinely fresh account). The one
  difference: the quiz **never `Auth::login`s** (a lead-gen quiz is not a login). The resolved lead is
  then linked to the submission ([`PropertyMatchRepository::attachVerifiedResult`](/src/PropertyMatch/Repositories/PropertyMatchRepository.php)),
  and a **freshly-minted** lead gets the `SOURCE_PROPERTY_MATCH` first-touch attribution. See the
  [identity foundation](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md).
- **The `result` step's consult CTA is the one button that is not just a wizard step**
  ([`Partials/ConsultCta.vue`](/resources/js/Pages/Main/PropertyMatch/Partials/ConsultCta.vue),
  2026-08-10). Reaching `result` means the quiz already did its job — all that is left is whether the
  visitor asks for a human, so that button stopped looking like the other six `.pm-btn`s. Four cues,
  each for a different reason: a **shine** (peripheral vision registers a moving specular highlight
  before it registers colour — and `.pm-btn` had been authored with `background-size: 200% 100%` for
  exactly this since day one, simply never animated), a **halo** that makes it the brightest thing on
  a near-black page, a **ping** that re-asks once the eye has settled on the card, and a **tapping
  hand** that shows the gesture instead of naming it, so it needs no copy and no translation. The
  three periods are deliberately different (3s / 2.9s / 3.4s / 3.8s) — synced, they read as one busy
  rhythm rather than three invitations.
- **It is mounted TWICE on that screen, and only the top one is loud.** The screen is taller than a
  phone — card, reasons, heading and a paragraph all came before the ask — so the button this whole
  quiz exists to earn sat below the fold at the moment the reader was most convinced. The order is now
  **heading → reason → button → `你的 MATCH` → card → `quiet` button → retake**.
  - ⚠️ **The heading and the paragraph moved up WITH the button, not after it.** Lifting the button
    alone fixed the position and introduced a worse problem: a naked gold button asking for a call
    before a single word had said what the call *is*. A button is a request, and a request without its
    reason is just pressure. The second mounting needs no heading — whoever reaches it read the reason
    a screen ago — so `.pm-consult` now holds only the quiet button and the retake link, and
    `text-align: center` moved to `.pm-cta-top`, since `.pm-consult-title` / `-body` never set their
    own and had been inheriting it from a parent that is no longer theirs.
  - ⚠️ The second mounting drops the ping, the halo and the hand: two competing attention-grabs on one
    screen do not attract twice the attention, the eye settles on neither.
- ⚠️ **Where the animations live is load-bearing.** The halo and ping are on the WRAPPER's
  pseudo-elements and the entrance animation on the wrapper itself, because a **running animation
  beats a hover rule for the same property** — on the button, the halo would have made `:hover`
  impossible to answer with and the entrance animation's `both` fill would have pinned the transform
  and swallowed the hover lift. Same reason the arrow's hover rule sets `animation: none` rather than
  `paused` (a paused animation still holds its computed transform). The hand is `pointer-events: none`
  and hides on `:hover`, since a hint still tapping under the reader's own cursor reads as a stuck
  animation. All motion is dropped under `prefers-reduced-motion`; the **weight** (gold, size, shadow)
  is not, since it still has to read as the primary action.

## How it works

### Data — one table, JSON snapshot (schema-change-free)
[`property_match_submissions`](/database/migrations/2026_07_23_100001_create_property_match_submissions_table.php)
([`Src\PropertyMatch\PropertyMatchSubmission`](/src/PropertyMatch/PropertyMatchSubmission.php)) holds:

- `lead_id` — the identity-resolved link (**nullable**: capture never depends on it) + a light
  `name/email/phone/ip` snapshot (the fallback so a lead is *never lost*).
- **`answers`** (JSON) — `[{q_key, q_label, a_key, a_label}]`, the ordered trail **with human labels
  snapshot at submit time** (a stored submission always reads as what THAT buyer saw, even after the
  frontend copy changes — same idea as frozen membership terms).
- **`result`** (JSON) — the frozen recommendation card (title/unit/tag/tenure/roi/price/reasons).
- **`match_key`** — the result key, **recomputed server-side** by
  [`PropertyMatchEngine`](/src/PropertyMatch/Support/PropertyMatchEngine.php) (a 1:1 mirror of the
  frontend `decideKey`) as the **tamper-proof** source of truth, and denormalised into its own indexed
  column for the admin "recommendation" filter/counts.
- **`interest_property`** — the buyer's own declared pick. It rides inside `answers` like every other
  question (so the trail and the admin answer grid show it for free); the column is the same value
  **denormalised out of the JSON**, exactly like `match_key`, so the board can filter/GROUP BY it.
  Nullable — the question is optional and an older client simply omits it.
- Pipeline: `status` (new→booked→contacted→closed), `appointment_setter_id`/`closer_admin_id`,
  `appointment_at`, `tags`, `is_verified`. Immutable admin notes live in
  [`property_match_remarks`](/database/migrations/2026_07_23_100002_create_property_match_remarks_table.php)
  (append-only; author name snapshot).

`PropertyMatchEngine` also owns the answer **whitelist** (`sanitizeAnswers`), so a crafted payload
cannot smuggle values into the stored answers or the decision.

### Public flow — [`Main\PropertyMatch\PropertyMatchController`](/app/Http/Controllers/Main/PropertyMatch/PropertyMatchController.php)
`show` renders the quiz. `submit` saves the snapshot (recomputing `match_key`). `start` + `verify`
send/verify the two OTP codes (reusing [`PasswordlessAuth`](/app/Services/Auth/PasswordlessAuth.php),
the same engine as `/register`, challenges in the session); `verify` then runs
`PasswordlessAuth::completeRegistration` (create / auto-merge / supersede / pending — WITHOUT sign-in)
and links the resolved lead via
[`PropertyMatchRepository::attachVerifiedResult`](/src/PropertyMatch/Repositories/PropertyMatchRepository.php).
`booked` marks the slot. All are throttled public routes; results flow back through the one-shot `pm`
Inertia flash prop so the client-side wizard advances after each round-trip.

### The verification UI is the SHARED component
The email+phone dual-code step is
[`Components/Auth/ContactVerification.vue`](/resources/js/Components/Auth/ContactVerification.vue) —
the **same component** `/register` uses ([`Auth/Register.vue`](/resources/js/Pages/Auth/Register.vue)).
It owns the identity/codes/send-failed steps + its two Inertia `useForm`s and posts to the endpoints
passed as `startUrl`/`verifyUrl`; the parent forwards its own flash props (`sendFailed`, `devOtp`),
passes bilingual `labels` + an `extra` payload (the submission uuid), and reacts to `@verified`.
**Change the verification UX once and both `/register` and Property Match update together.**

### Admin — the person-centric sales workbench
`/manage/property-match` shows **one row per safely identified person**, not one row per attempt.

**Safe grouping (the load-bearing identity rule).** Submissions sharing one non-null `lead_id`
(a *verified* CRM link) form one person; every `lead_id = null` capture stays its **own isolated
row even when its claimed email/phone matches another row** — claimed contact data never merges
people. Reads project through
[`PropertyMatchAdminQuery`](/src/PropertyMatch/Queries/PropertyMatchAdminQuery.php): a two-stage
SQL pipeline where the existential attempt filters (search / recommendation / submitted-date)
pick person keys, a windowed `UNION ALL` summary re-reads ALL of each person's attempts (so the
row keeps its true `×N` attempt count and its latest representative), and the person-summary
filters (highest status, setter, closer, scheduled, verified-at-least-once) apply after grouping.
All actions address the latest attempt's **submission UUID**; numeric IDs are never exposed.

**Routes & permission split** (all under `permission:view-property-match`; mutations additionally
`manage-property-match`):

| Route | Verb | What |
| --- | --- | --- |
| `/manage/property-match` | GET | Inertia person list / calendar (`view=list\|calendar`, `month=YYYY-MM`) |
| `{uuid}/detail` | GET | Lazy JSON person detail: canonical contact + CRM + every attempt (newest first, full answer trail + captured snapshot) |
| `{uuid}/remarks` | GET | Cursor-paginated merged remarks, scoped to the person (never crosses a lead boundary) |
| `{uuid}/status` | PUT | Pipeline status (fans out person-wide via `PropertyMatchRepository::changeStatus`) |
| `{uuid}/assign` | PUT | Partial assignment: `appointment_setter` / `closer` (staff **UUIDs**) / `appointment_at`; only present keys are written, null clears |
| `{uuid}/remarks` | POST | Append an immutable remark (author-name snapshot) |
| `{uuid}/tags` | PUT | Overwrite the person tag set |

**Stats & distribution units.** The six headline cards (TOTAL / NEW / BOOKED+ / CONVERSION /
SCHEDULED / UNASSIGNED CLOSER) count **safe people** over the whole non-deleted dataset and do
not change with list filters (the paginator total carries the filtered count). The
recommendation-distribution chips count **attempts** per `match_key`, labelled with the newest
non-empty frozen result title; clicking a chip toggles the recommendation filter.

**Canonical petaV3 CRM columns** (bulk-hydrated per page — never per row; unlinked rows show
`Unlinked capture` instead of inferring an identity from claimed contact data):
membership names from active `MemberSubscription`→`Membership`; paid totals from non-deleted
`MemberSubscription.price_paid` **grouped by their stored `currency` (never guessed)**; the
newest Active/Completed `Src\Engagement\Booking` with `Project::canonicalName()` + unit
(Cancelled bookings are never displayed); Zoom minutes as the `webinarAttendances()`
`duration_seconds` sum. Legacy petaV2 tables (`wf_stripe_charges`, `wf_property_bookings`,
`zoom_event_participants`) are **forbidden** here.

**Assignment guard.** Setter/closer targets resolve through the actor-scoped
`assignableManagerQuery` pool; the write locks the complete person row set in stable ID order
plus every involved staff `User`+`Admin`+role-pivot row, so a group-scoped actor can never take
over a person whose existing setter/closer belongs to another group (403), and targets must be
active manage users in the actor's group (422). An inactive historical same-group owner remains
correctable. The read row's `assignment_mutable` only hides the controls — the locked guard is
authoritative.

**Module-local appointment semantics.** `appointment_at` is Property Match's own follow-up time:
it is parsed from `datetime-local` in `config('app.user_timezone')`, stored in
`config('app.timezone')`, and rendered back as an exact `local_input` string (no browser
timezone conversion). It drives only this module's calendar — **no canonical `appointments` rows
are created or synchronized**. The calendar is a fixed 42-cell Monday-first grid covering the
adjacent-month boundary days, one deterministically-colored event per person; clicking an event
jumps to the List view with `focus={uuid}` (a read-only navigation hint) which promotes, expands
and scrolls the person's row.

**Append-only remarks & tag limits.** Remarks have create + read routes only — no update/delete
surface exists anywhere (route-scan + repository-reflection tests lock this). Tags are limited to
**12 tags × 24 characters** at BOTH the request and the repository, so an over-limit request is
rejected loudly instead of silently truncated.

**Explicit non-goals** (need their own plan + approval): no change to the public quiz /
ContactVerification / OTP / identity resolution; no petaV2 data import; no email/phone
deduplication; no new status model or Kanban; no automatic lead owner/distribution assignment;
no `appointments`-table sync; no CSV/Excel export; no general-Calendar refactor.

The **per-lead history** also stays on the lead's Show page: **Portal Engagement → Property
Match** tab ([`PropertyMatchTab.vue`](/resources/js/Pages/Manage/Leads/Partials/Tabs/PropertyMatchTab.vue)).

### Identity-graph coverage
`property_match_submissions.lead_id` is classified `REPOINT` / `DELETE_BY_LEAD` in
[`IdentityChildMap`](/src/Lead/Support/IdentityChildMap.php), so an account **merge** re-points a
person's submissions onto the survivor and a privacy **purge** deletes them — automatically (the
schema-census test forced the classification). The two staff columns are `SKIP`.

## Related files

- **Backend:** [PropertyMatchSubmission](/src/PropertyMatch/PropertyMatchSubmission.php) · [PropertyMatchRemark](/src/PropertyMatch/PropertyMatchRemark.php) · [PropertyMatchEngine](/src/PropertyMatch/Support/PropertyMatchEngine.php) · [PropertyMatchRepository](/src/PropertyMatch/Repositories/PropertyMatchRepository.php)
- **Public:** [PropertyMatchController](/app/Http/Controllers/Main/PropertyMatch/PropertyMatchController.php) · [Index.vue quiz](/resources/js/Pages/Main/PropertyMatch/Index.vue) · [content.js](/resources/js/Pages/Main/PropertyMatch/content.js) · [Partials/ConsultCta.vue](/resources/js/Pages/Main/PropertyMatch/Partials/ConsultCta.vue) (the `result` step's consultation button — mounted twice, loud above the card and `quiet` below it)
- **Shared:** [ContactVerification.vue](/resources/js/Components/Auth/ContactVerification.vue) (also used by `/register`)
- **Admin:** [SubmissionsController](/app/Http/Controllers/Manage/PropertyMatch/SubmissionsController.php) · [PropertyMatchAdminQuery](/src/PropertyMatch/Queries/PropertyMatchAdminQuery.php) · [SubmissionQueryRequest](/app/Http/Requests/Manage/PropertyMatch/SubmissionQueryRequest.php) · [AssignRequest](/app/Http/Requests/Manage/PropertyMatch/AssignRequest.php) · [StoreRemarkRequest](/app/Http/Requests/Manage/PropertyMatch/StoreRemarkRequest.php) · [UpdateTagsRequest](/app/Http/Requests/Manage/PropertyMatch/UpdateTagsRequest.php) · [Manage/PropertyMatch/Index.vue](/resources/js/Pages/Manage/PropertyMatch/Index.vue) · [PersonTable.vue](/resources/js/Pages/Manage/PropertyMatch/Partials/PersonTable.vue) · [PersonDetail.vue](/resources/js/Pages/Manage/PropertyMatch/Partials/PersonDetail.vue) · [CalendarTab.vue](/resources/js/Pages/Manage/PropertyMatch/Partials/CalendarTab.vue) · [admin.js](/resources/js/Pages/Manage/PropertyMatch/admin.js) · [PropertyMatchTab.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/PropertyMatchTab.vue)
- **Migrations:** `2026_07_23_100001_create_property_match_submissions_table` · `2026_07_23_100002_create_property_match_remarks_table` · `2026_08_03_100002_add_interest_property_to_property_match_submissions` (the workbench added **no** migration — the workflow/remark fields already existed)
- **Tests:** [PropertyMatchEngineTest](/tests/Unit/PropertyMatch/PropertyMatchEngineTest.php) · [PropertyMatchRepositoryTest](/tests/Feature/PropertyMatch/PropertyMatchRepositoryTest.php) · [PropertyMatchFlowTest](/tests/Feature/PropertyMatch/PropertyMatchFlowTest.php) · [ManagePropertyMatchTest](/tests/Feature/PropertyMatch/ManagePropertyMatchTest.php) · [admin.test.js](/resources/js/Pages/Manage/PropertyMatch/admin.test.js)

## Related modules
- [Users · Leads · Admins · Login](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md) — the OTP engine + the account-takeover / login-grant rules this reuses.
- [Lead Linking](/docs/modules_handbook/shared/lead-linking/readMe.md) — the `LeadLinker` that turns the two proven keys into one lead.
