Public: anyone can read this; the crew can change it.
A buyer who opens a property page sees the agent's brand, not the Listing Launch tool's header, sign-in and developer footer #120
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/119-a-buyer-who-opens-a-property-page-sees-t → main 2 commits fork 2 threads · 2 unresolved
5 files
changed +113
−20
docs/design/areas/property.md +1
| @@ -6,3 +6,4 @@ New component classes, all built from existing tokens: | ||
| 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. |
| 9 | - `public-head` (`public-brand`, `public-back`), `public-foot` — the agent's own header and footer on `/homes/:id`, in place of the tool's. |
src/pages/layout.ts +42 −15
| @@ -32,6 +32,17 @@ export interface Frame { | ||
| 32 | 32 | readonly image?: { readonly url: string; readonly alt: string }; |
| 33 | 33 | /** The share title, when it is not the page's own title. */ |
| 34 | 34 | readonly shareTitle?: string; |
| 35 | /** | |
| 36 | * The buyer's face: the agent's brand in place of the tool's header and | |
| 37 | * footer. `back` is a link to the listing, only for its agent or an admin. | |
| 38 | */ | |
| 39 | readonly agent?: { | |
| 40 | readonly name: string; | |
| 41 | readonly brokerage: string; | |
| 42 | readonly contact: string; | |
| 43 | readonly palette: string; | |
| 44 | readonly back?: string; | |
| 45 | }; | |
| 35 | 46 | } |
| 36 | 47 | |
| 37 | 48 | /** "Listing Launch", from the package's "listing-launch". */ |
| @@ -56,6 +67,35 @@ export function layout(body: string, who: Who, frame: Frame = {}): string { | ||
| 56 | 67 | ? `<a class="button button-quiet" href="/__forge/signin?to=${escape(path)}">Sign in</a>` |
| 57 | 68 | : `<span class="you"><b>${escape(who.name)}</b>${who.role === "admin" ? " · admin" : ""}</span> |
| 58 | 69 | <a class="button button-quiet" href="/__forge/signout">Sign out</a>`; |
| 70 | const a = frame.agent; | |
| 71 | const head = | |
| 72 | a === undefined | |
| 73 | ? `<header class="head"> | |
| 74 | <div> | |
| 75 | <h1><a class="brandmark" href="/">${escape(appName)}</a></h1> | |
| 76 | <p class="muted">${escape(description)}</p> | |
| 77 | </div> | |
| 78 | <nav class="row"> | |
| 79 | <span class="pill" data-env="${escape(env)}">${escape(where)}</span>${commit ? `<span class="muted mono">${escape(commit)}</span>` : ""} | |
| 80 | ${door} | |
| 81 | </nav> | |
| 82 | </header>` | |
| 83 | : `<header class="head public-head palette-${escape(a.palette)}"> | |
| 84 | <p class="public-brand brand"><b>${escape(a.name)}</b> · ${escape(a.brokerage)}</p>${a.back === undefined ? "" : ` | |
| 85 | <a class="public-back muted" href="${escape(a.back)}">Back to the listing</a>`} | |
| 86 | </header>`; | |
| 87 | const foot = | |
| 88 | a === undefined | |
| 89 | ? `<footer class="muted"> | |
| 90 | ${ssh === undefined ? "<p>The terminal is off here.</p>" : `<p>Terminal: <code>${escape(ssh)}</code></p>`} | |
| 91 | <p>Also at <a href="/api/listings">/api/listings</a> (<a href="/openapi.json">described</a>) and <code>POST /mcp</code>, with the same rules. Team <a href="/notes">notes</a>.</p> | |
| 92 | <p>Something wrong? <a href="/__forge/report">Report a problem</a>, no account needed.</p> | |
| 93 | </footer>` | |
| 94 | : `<footer class="muted public-foot"> | |
| 95 | <p>Equal Housing Opportunity</p> | |
| 96 | <p>${escape(a.contact)}</p> | |
| 97 | <p><small><a href="/__forge/report">Report a problem</a></small></p> | |
| 98 | </footer>`; | |
| 59 | 99 | return `<!doctype html> |
| 60 | 100 | <html lang="en"> |
| 61 | 101 | <meta charset="utf-8"> |
| @@ -84,23 +124,10 @@ ${image === undefined ? "" : `<meta property="og:image" content="${escape(image. | ||
| 84 | 124 | <body${frame.kind === undefined ? "" : ` class="${escape(frame.kind)}"`}> |
| 85 | 125 | <a class="skip" href="#main">Skip to content</a> |
| 86 | 126 | <div class="page"> |
| 87 | <header class="head"> | |
| 88 | <div> | |
| 89 | <h1><a class="brandmark" href="/">${escape(appName)}</a></h1> | |
| 90 | <p class="muted">${escape(description)}</p> | |
| 91 | </div> | |
| 92 | <nav class="row"> | |
| 93 | <span class="pill" data-env="${escape(env)}">${escape(where)}</span>${commit ? `<span class="muted mono">${escape(commit)}</span>` : ""} | |
| 94 | ${door} | |
| 95 | </nav> | |
| 96 | </header> | |
| 127 | ${head} | |
| 97 | 128 | <main id="main" tabindex="-1">${body}</main> |
| 98 | 129 | <div class="sr" id="announce" role="status" aria-live="polite" data-announce></div> |
| 99 | <footer class="muted"> | |
| 100 | ${ssh === undefined ? "<p>The terminal is off here.</p>" : `<p>Terminal: <code>${escape(ssh)}</code></p>`} | |
| 101 | <p>Also at <a href="/api/listings">/api/listings</a> (<a href="/openapi.json">described</a>) and <code>POST /mcp</code>, with the same rules. Team <a href="/notes">notes</a>.</p> | |
| 102 | <p>Something wrong? <a href="/__forge/report">Report a problem</a>, no account needed.</p> | |
| 103 | </footer> | |
| 130 | ${foot} | |
| 104 | 131 | </div> |
| 105 | 132 | </body> |
| 106 | 133 | </html> |
src/pages/property.ts +14 −1
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { factItems, factsLine, isOffMarket, money, statusLine, whenOf } from "../generate.ts"; |
| 9 | 9 | import { publicOrigin } from "../env.ts"; |
| 10 | import { permit } from "../contract.ts"; | |
| 10 | 11 | import { escape } from "../http.ts"; |
| 11 | 12 | import { altOf, propertyPath, publicDescription, type Launched } from "../listing.ts"; |
| 12 | 13 | import type { Who } from "../who.ts"; |
| @@ -14,6 +15,17 @@ import { monthlyPayment, PAYMENT_NOTE } from "../payment.ts"; | ||
| 14 | 15 | import { layout } from "./layout.ts"; |
| 15 | 16 | import { photoUrl } from "./listing.ts"; |
| 16 | 17 | |
| 18 | /** The agent's brand for the page frame; the way back shows only to the listing's agent or an admin. */ | |
| 19 | const faceOf = (who: Who, l: Launched) => ({ | |
| 20 | name: l.agent_name, | |
| 21 | brokerage: l.brokerage, | |
| 22 | contact: `${l.agent_phone} · ${l.agent_email}`, | |
| 23 | palette: l.palette, | |
| 24 | ...(permit(who, "listing", "update", { ...l }) === "yes" | |
| 25 | ? { back: `/listings/${l.id}` } | |
| 26 | : {}), | |
| 27 | }); | |
| 28 | ||
| 17 | 29 | /** Where a withdrawn or expired listing's address leads: the address and the agent, and no price, words or open house. */ |
| 18 | 30 | function offMarketPage(who: Who, l: Launched): string { |
| 19 | 31 | return layout( |
| @@ -28,6 +40,7 @@ function offMarketPage(who: Who, l: Launched): string { | ||
| 28 | 40 | who, |
| 29 | 41 | { |
| 30 | 42 | path: propertyPath(l), |
| 43 | agent: faceOf(who, l), | |
| 31 | 44 | heading: l.address, |
| 32 | 45 | shareTitle: l.address, |
| 33 | 46 | description: "This home is no longer available.", |
| @@ -83,11 +96,11 @@ export function propertyPage(input: { who: Who; listing: Launched }): string { | ||
| 83 | 96 | ${words === null ? "" : `<p class="home-words">${escape(words).replace(/\n/g, "<br>")}</p>`} |
| 84 | 97 | ${more.length === 0 ? "" : `<ul class="strip">${more.map((p, i) => `<li><img src="${escape(photoUrl(l, p))}" alt="${escape(altOf(l, p, `Photo ${i + 2} of ${l.address}`))}" width="240" height="160" loading="lazy"></li>`).join("")}</ul>`} |
| 85 | 98 | ${agentFooter(l)} |
| 86 | <p class="muted"><small>Equal Housing Opportunity.</small></p> | |
| 87 | 99 | </article>`, |
| 88 | 100 | who, |
| 89 | 101 | { |
| 90 | 102 | path: propertyPath(l), |
| 103 | agent: faceOf(who, l), | |
| 91 | 104 | heading: l.address, |
| 92 | 105 | shareTitle: `${l.address} · ${money(l.price)}`, |
| 93 | 106 | description: [factsLine(l), words === null ? "" : firstSentence(words)].filter(Boolean).join(". ").replace(/\.\.$/, "."), |
src/property.test.ts +41 −4
| @@ -12,12 +12,12 @@ const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "property-") | ||
| 12 | 12 | const base = `http://${server.hostname}:${server.port}`; |
| 13 | 13 | afterAll(() => server.stop(true)); |
| 14 | 14 | |
| 15 | const as = (id: string) => { | |
| 16 | const identity = `${id}|${id}|user|${Date.now() + 600_000}`; | |
| 15 | const as = (id: string, role = "user") => { | |
| 16 | const identity = `${id}|${id}|${role}|${Date.now() + 600_000}`; | |
| 17 | 17 | return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) }; |
| 18 | 18 | }; |
| 19 | const call = (path: string, who?: string, init: RequestInit = {}) => | |
| 20 | fetch(base + path, { redirect: "manual", ...init, headers: who === undefined ? {} : as(who) }); | |
| 19 | const call = (path: string, who?: string, init: RequestInit = {}, role = "user") => | |
| 20 | fetch(base + path, { redirect: "manual", ...init, headers: who === undefined ? {} : as(who, role) }); | |
| 21 | 21 | |
| 22 | 22 | const form = new FormData(); |
| 23 | 23 | for (const [k, v] of Object.entries({ |
| @@ -223,3 +223,40 @@ test("a $0 price shows no estimated payment on the page", () => { | ||
| 223 | 223 | expect(html).not.toContain("Est. $"); |
| 224 | 224 | expect(html).not.toContain("loan offer"); |
| 225 | 225 | }); |
| 226 | ||
| 227 | test("the buyer's page wears the agent's brand, not the tool's chrome; the home page keeps it", async () => { | |
| 228 | const html = await (await call(`/homes/${id}`)).text(); | |
| 229 | expect(html).toContain("Pat Agent"); | |
| 230 | expect(html).toContain("Equal Housing Opportunity"); | |
| 231 | expect(html).toContain("/__forge/report"); | |
| 232 | for (const gone of ["Sign in", "/api/listings", "/notes", "The terminal is off here", "Terminal:", "/openapi.json", "Back to the listing"]) | |
| 233 | expect(html).not.toContain(gone); | |
| 234 | const home = await (await call("/")).text(); | |
| 235 | for (const kept of ["Sign in", "/api/listings", "/notes", "/__forge/report"]) expect(home).toContain(kept); | |
| 236 | }); | |
| 237 | ||
| 238 | test("the listing's agent and an admin get one way back; another user does not", async () => { | |
| 239 | expect(await (await call(`/homes/${id}`, "pat")).text()).toContain(`href="/listings/${id}"`); | |
| 240 | expect(await (await call(`/homes/${id}`, "other")).text()).not.toContain("Back to the listing"); | |
Add the admin case; the issue names agent or admin. Also cover the off-market page frame. |
||
| 241 | }); | |
| 242 | ||
| 243 | test("an admin gets the way back too, and Equal Housing shows once", async () => { | |
| 244 | const html = await (await call(`/homes/${id}`, "root", {}, "admin")).text(); | |
| 245 | expect(html).toContain(`href="/listings/${id}"`); | |
| 246 | expect(html.split("Equal Housing Opportunity").length - 1).toBe(1); | |
| 247 | }); | |
| 248 | ||
| 249 | test("the off-market page wears the agent's frame: no sign-in, a way back for the author only", async () => { | |
| 250 | const s = await call(`/listings/${id}/status`, "pat", { | |
| 251 | method: "POST", | |
| 252 | body: new URLSearchParams({ status: "withdrawn" }), | |
| 253 | headers: { ...as("pat"), "content-type": "application/x-www-form-urlencoded" }, | |
| 254 | }); | |
| 255 | expect(s.status).toBeLessThan(400); | |
| 256 | const anon = await (await call(`/homes/${id}`)).text(); | |
| 257 | expect(anon).toContain("no longer on the market"); | |
| 258 | expect(anon).toContain("Pat Agent"); | |
| 259 | expect(anon).not.toContain("Sign in"); | |
| 260 | expect(anon).not.toContain("Back to the listing"); | |
| 261 | expect(await (await call(`/homes/${id}`, "pat")).text()).toContain(`href="/listings/${id}"`); | |
| 262 | }); |
static/app.css +15
| @@ -1434,3 +1434,18 @@ main:focus { | ||
| 1434 | 1434 | display: inline-block; |
| 1435 | 1435 | min-width: 24ch; |
| 1436 | 1436 | } |
| 1437 | ||
| 1438 | /* The buyer's page: the agent's name in place of the tool's header and footer. */ | |
| 1439 | .public-head { | |
| 1440 | align-items: baseline; | |
| 1441 | } | |
| 1442 | .public-brand { | |
| 1443 | margin: 0; | |
| 1444 | font-size: var(, 1.125rem); | |
| 1445 | } | |
| 1446 | .public-back { | |
| 1447 | font-size: var(); | |
| 1448 | } | |
| 1449 | .public-foot { | |
| 1450 | text-align: center; | |
| 1451 | } |
Role compared by hand. Use permit(who, "listing", "update", { ...l }) === "yes" for the back link.