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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/listing-launch:mason/133-a-buyer-on-the-property-page-texts-or-em → main 1 commit fork

  • Merged. mason/133-a-buyer-on-the-property-page-texts-or-em was merged into main as b107334.

Review

Sign in to review this change.
6 files changed +112 −1
README.md +5
@@ -103,6 +103,11 @@ to make a listing of your own.
103103 Instagram or Facebook. A draft has none. Without JavaScript the same text
104104 sits in a selectable read-only box under "Copy post".
105105
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
106111 Copy for MLS works the same way on the MLS tab: once the MLS description is
107112 approved (the sample *412 Alder Lane* MLS description already is), a **Copy
108113 for MLS** button copies the approved words as plain text, with no link,
docs/design/areas/property.md +1 −1
@@ -2,7 +2,7 @@
22
33 New component classes, all built from existing tokens:
44
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`.
66 - `property-link` — the link and Copy button on the listing page.
77 - `flyer-link` — the property page address printed on the flyer.
88 - `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)}?&amp;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}`)}&amp;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";
1212 import { altOf, propertyPath, publicDescription, type Launched } from "../listing.ts";
1313 import type { Who } from "../who.ts";
1414 import { monthlyPayment, PAYMENT_NOTE } from "../payment.ts";
15 import { inquiryLinks } from "../inquiry.ts";
1516 import { layout } from "./layout.ts";
1617 import { photoUrl } from "./listing.ts";
1718
@@ -88,6 +89,7 @@ export function propertyPage(input: { who: Who; listing: Launched }): string {
8889 <p class="home-price brand">${money(l.price)}</p>
8990 ${paymentLines(l)}
9091 <p><span class="pill status-pill status-${l.status}">${escape(status)}</span></p>
92 ${inquiryLinks(l)}
9193 </header>
9294 <ul class="home-facts brand-fill">
9395 ${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
260260 expect(anon).not.toContain("Back to the listing");
261261 expect(await (await call(`/homes/${id}`, "pat")).text()).toContain(`href="/listings/${id}"`);
262262 });
263
264 // --- one-tap inquiry --------------------------------------------------------
265 const hrefs = (html: string) => [...html.matchAll(/<a class="button[^"]*" href="((?:sms|mailto):[^"]*)">/g)].map((m) => m[1]!.replace(/&amp;/g, "&").replace(/&#39;/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 {
10771077 padding: var(--space-3) var(--space-4);
10781078 border-radius: var(--radius-md);
10791079 }
1080 .home-reach {
1081 display: flex;
1082 flex-wrap: wrap;
1083 gap: var(--space-3);
1084 }
10801085 .home-open {
10811086 font-weight: 600;
10821087 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs