Public: anyone can read this; the crew can change it.
A buyer on the property page texts or emails the agent about this home in one tap, with the address and link already written #134
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/133-a-buyer-on-the-property-page-texts-or-em → main 1 commit fork
6 files
changed +112
−1
README.md +5
| @@ -103,6 +103,11 @@ to make a listing of your own. | ||
| 103 | 103 | Instagram or Facebook. A draft has none. Without JavaScript the same text |
| 104 | 104 | sits in a selectable read-only box under "Copy post". |
| 105 | 105 | |
| 106 | **Text or email the agent.** Under the price on `/homes/:id`, **Text Morgan** | |
| 107 | and **Email Morgan** are plain `sms:` and `mailto:` links whose message | |
| 108 | already names the address, the page link and any open house; a missing phone | |
| 109 | or email hides its button, and the off-market page shows neither. | |
| 110 | ||
| 106 | 111 | Copy for MLS works the same way on the MLS tab: once the MLS description is |
| 107 | 112 | approved (the sample *412 Alder Lane* MLS description already is), a **Copy |
| 108 | 113 | for MLS** button copies the approved words as plain text, with no link, |
docs/design/areas/property.md +1 −1
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | New component classes, all built from existing tokens: |
| 4 | 4 | |
| 5 | - `home` (`home-hero`, `home-head`, `home-price`, `home-payment`, `home-payment-note`, `home-facts`, `home-open`, `home-words`, `home-agent`, `home-face`, `home-name`, `home-notice`) — the buyer's page at `/homes/:id`. | |
| 5 | - `home` (`home-hero`, `home-head`, `home-price`, `home-payment`, `home-payment-note`, `home-facts`, `home-open`, `home-words`, `home-agent`, `home-face`, `home-name`, `home-notice`, `home-reach`) — the buyer's page at `/homes/:id`. | |
| 6 | 6 | - `property-link` — the link and Copy button on the listing page. |
| 7 | 7 | - `flyer-link` — the property page address printed on the flyer. |
| 8 | 8 | - `email-cta`, `email-button` — the "See the home" button in the email. |
src/inquiry.ts added +41
| @@ -0,0 +1,41 @@ | ||
| 1 | /** | |
| 2 | * The buyer's one-tap inquiry: the `sms:` and `mailto:` links on the property | |
| 3 | * page, with the address and the page's absolute URL already written. Plain | |
| 4 | * data in, plain strings out; nothing is stored. | |
| 5 | */ | |
| 6 | ||
| 7 | import { whenOf } from "./generate.ts"; | |
| 8 | import { publicOrigin } from "./env.ts"; | |
| 9 | import { escape } from "./http.ts"; | |
| 10 | import { propertyPath, type Launched } from "./listing.ts"; | |
| 11 | ||
| 12 | /** The agent's first name, for "Text Morgan"; the whole name when it has one word. */ | |
| 13 | export const firstName = (name: string): string => name.trim().split(/\s+/)[0] ?? name; | |
| 14 | ||
| 15 | /** The inquiry's words: short, polite, naming the home and its link, and the open house when one is coming. */ | |
| 16 | export function inquiryWords(l: Launched): string { | |
| 17 | const open = | |
| 18 | l.status === "active" && l.open_houses.length > 0 | |
| 19 | ? ` Is the open house on ${l.open_houses.map(whenOf).join(" and ")} still on?` | |
| 20 | : ""; | |
| 21 | return `Hi ${firstName(l.agent_name)}, I'm interested in ${l.address}. Is it still available?${open} ${propertyPath(l, publicOrigin)}`; | |
| 22 | } | |
| 23 | ||
| 24 | const enc = (s: string): string => escape(encodeURIComponent(s)); | |
| 25 | ||
| 26 | /** Two plain links (text, email); a channel the listing has no number or address for is left out. */ | |
| 27 | export function inquiryLinks(l: Launched): string { | |
| 28 | const digits = l.agent_phone.replace(/[^0-9+]/g, ""); | |
| 29 | const email = l.agent_email.trim(); | |
| 30 | const words = inquiryWords(l); | |
| 31 | const name = escape(firstName(l.agent_name)); | |
| 32 | const text = | |
| 33 | digits.replace(/\+/g, "") === "" | |
| 34 | ? "" | |
| 35 | : `<a class="button" href="sms:${enc(digits)}?&body=${enc(words)}">Text ${name}</a>`; | |
| 36 | const mail = | |
| 37 | email === "" | |
| 38 | ? "" | |
| 39 | : `<a class="button button-quiet" href="mailto:${enc(email).replace(/%40/g, "@")}?subject=${enc(`About ${l.address}`)}&body=${enc(words)}">Email ${name}</a>`; | |
| 40 | return text + mail === "" ? "" : `<p class="home-reach">${text}${mail}</p>`; | |
| 41 | } |
src/pages/property.ts +2
| @@ -12,6 +12,7 @@ import { escape } from "../http.ts"; | ||
| 12 | 12 | import { altOf, propertyPath, publicDescription, type Launched } from "../listing.ts"; |
| 13 | 13 | import type { Who } from "../who.ts"; |
| 14 | 14 | import { monthlyPayment, PAYMENT_NOTE } from "../payment.ts"; |
| 15 | import { inquiryLinks } from "../inquiry.ts"; | |
| 15 | 16 | import { layout } from "./layout.ts"; |
| 16 | 17 | import { photoUrl } from "./listing.ts"; |
| 17 | 18 | |
| @@ -88,6 +89,7 @@ export function propertyPage(input: { who: Who; listing: Launched }): string { | ||
| 88 | 89 | <p class="home-price brand">${money(l.price)}</p> |
| 89 | 90 | ${paymentLines(l)} |
| 90 | 91 | <p><span class="pill status-pill status-${l.status}">${escape(status)}</span></p> |
| 92 | ${inquiryLinks(l)} | |
| 91 | 93 | </header> |
| 92 | 94 | <ul class="home-facts brand-fill"> |
| 93 | 95 | ${factItems(l).map((i) => `<li><b>${escape(i.value)}</b> ${i.label}</li>`).join("\n ")} |
src/property.test.ts +58
| @@ -260,3 +260,61 @@ test("the off-market page wears the agent's frame: no sign-in, a way back for th | ||
| 260 | 260 | expect(anon).not.toContain("Back to the listing"); |
| 261 | 261 | expect(await (await call(`/homes/${id}`, "pat")).text()).toContain(`href="/listings/${id}"`); |
| 262 | 262 | }); |
| 263 | ||
| 264 | // --- one-tap inquiry -------------------------------------------------------- | |
| 265 | const hrefs = (html: string) => [...html.matchAll(/<a class="button[^"]*" href="((?:sms|mailto):[^"]*)">/g)].map((m) => m[1]!.replace(/&/g, "&").replace(/'/g, "'")); | |
| 266 | const approved = (over: Record<string, unknown> = {}) => { | |
| 267 | const { db, id, get } = preview(); | |
| 268 | for (const a of get().assets) approveAsset(db, me, { id, kind: a.kind, door: "page", seen: a.updated_at }); | |
| 269 | return { db, id, get, page: (more: Record<string, unknown> = {}) => propertyPage({ who: { role: "anon" }, listing: { ...get(), ...over, ...more } as never }) }; | |
| 270 | }; | |
| 271 | ||
| 272 | test("the page offers a text and an email link naming the address and the page link", () => { | |
| 273 | const { id, get, page } = approved({ open_houses: [] }); | |
| 274 | const [sms, mail] = hrefs(page()); | |
| 275 | const l = get(); | |
| 276 | const body = `Hi ${l.agent_name.split(" ")[0]}, I'm interested in ${l.address}. Is it still available? ${publicOrigin}/homes/${id}`; | |
| 277 | expect(sms).toMatch(/^sms:\+?\d+\?&body=/); | |
| 278 | expect(decodeURIComponent(sms!.split("?&body=")[1]!)).toBe(body); | |
| 279 | expect(mail).toStartWith(`mailto:${l.agent_email}?subject=`); | |
| 280 | expect(decodeURIComponent(/&body=(.*)$/.exec(mail!)![1]!)).toBe(body); | |
| 281 | expect(page()).toContain(`>Text ${l.agent_name.split(" ")[0]}<`); | |
| 282 | expect(page()).toContain(`>Email ${l.agent_name.split(" ")[0]}<`); | |
| 283 | expect(page()).toContain("tel:"); | |
| 284 | }); | |
| 285 | ||
| 286 | test("an upcoming open house is asked about", () => { | |
| 287 | const { page } = approved(); | |
| 288 | const [sms] = hrefs(page({ open_houses: [{ date: "2027-03-07", start: "13:00", end: "15:00" }] })); | |
| 289 | expect(decodeURIComponent(sms!.split("?&body=")[1]!)).toContain("Is the open house on Sunday, March 7, 1"); | |
| 290 | }); | |
| 291 | ||
| 292 | test("an address with & ? # a quote and < stays inside one well-formed href and decodes intact", () => { | |
| 293 | const address = `5 A&B? #9 "Q" <Lane>`; | |
| 294 | const { page } = approved({ address, open_houses: [] }); | |
| 295 | const html = page(); | |
| 296 | const tags = [...html.matchAll(/<a class="button[^"]*" href="(?:sms|mailto):[^"]*">/g)]; | |
| 297 | expect(tags.length).toBe(2); | |
| 298 | for (const t of tags) expect(t[0]).not.toMatch(/href="[^"]*[<>"'][^"]*"[^>]*"/); | |
| 299 | const [sms, mail] = hrefs(html); | |
| 300 | expect(decodeURIComponent(sms!.split("?&body=")[1]!)).toContain(`interested in ${address}.`); | |
| 301 | expect(decodeURIComponent(/subject=([^&]*)/.exec(mail!)![1]!)).toBe(`About ${address}`); | |
| 302 | }); | |
| 303 | ||
| 304 | test("no phone or no email hides that button; the contact block stays", () => { | |
| 305 | const { page } = approved(); | |
| 306 | const noPhone = page({ agent_phone: "" }); | |
| 307 | expect(hrefs(noPhone).map((h) => h.slice(0, 4))).toEqual(["mail"]); | |
| 308 | const noMail = page({ agent_email: "" }); | |
| 309 | expect(hrefs(noMail).map((h) => h.slice(0, 3))).toEqual(["sms"]); | |
| 310 | expect(page()).toContain('href="tel:'); | |
| 311 | expect(page()).toContain('href="mailto:'); | |
| 312 | }); | |
| 313 | ||
| 314 | test("the off-market page shows neither button", () => { | |
| 315 | const { db, id, get } = approved(); | |
| 316 | expect(setStatus(db, me, { id, status: "withdrawn" }).ok).toBe(true); | |
| 317 | const html = propertyPage({ who: { role: "anon" }, listing: get() }); | |
| 318 | expect(hrefs(html)).toEqual([]); | |
| 319 | expect(html).not.toContain("home-reach"); | |
| 320 | }); |
static/app.css +5
| @@ -1077,6 +1077,11 @@ main:focus { | ||
| 1077 | 1077 | padding: var() var(); |
| 1078 | 1078 | border-radius: var(); |
| 1079 | 1079 | } |
| 1080 | .home-reach { | |
| 1081 | display: flex; | |
| 1082 | flex-wrap: wrap; | |
| 1083 | gap: var(); | |
| 1084 | } | |
| 1080 | 1085 | .home-open { |
| 1081 | 1086 | font-weight: 600; |
| 1082 | 1087 | } |