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

Merged opened this

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

Merged

mason/listing-launch:mason/119-a-buyer-who-opens-a-property-page-sees-t → main 2 commits fork 2 threads · 2 unresolved

  • Merged. mason/119-a-buyer-who-opens-a-property-page-sees-t was merged into main as 154efbe.

Review

Sign in to review this change.
5 files changed +113 −20
docs/design/areas/property.md +1
@@ -6,3 +6,4 @@ New component classes, all built from existing tokens:
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.
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 {
3232 readonly image?: { readonly url: string; readonly alt: string };
3333 /** The share title, when it is not the page's own title. */
3434 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 };
3546 }
3647
3748 /** "Listing Launch", from the package's "listing-launch". */
@@ -56,6 +67,35 @@ export function layout(body: string, who: Who, frame: Frame = {}): string {
5667 ? `<a class="button button-quiet" href="/__forge/signin?to=${escape(path)}">Sign in</a>`
5768 : `<span class="you"><b>${escape(who.name)}</b>${who.role === "admin" ? " · admin" : ""}</span>
5869 <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>`;
5999 return `<!doctype html>
60100 <html lang="en">
61101 <meta charset="utf-8">
@@ -84,23 +124,10 @@ ${image === undefined ? "" : `<meta property="og:image" content="${escape(image.
84124 <body${frame.kind === undefined ? "" : ` class="${escape(frame.kind)}"`}>
85125 <a class="skip" href="#main">Skip to content</a>
86126 <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}
97128 <main id="main" tabindex="-1">${body}</main>
98129 <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}
104131 </div>
105132 </body>
106133 </html>
src/pages/property.ts +14 −1
@@ -7,6 +7,7 @@
77
88 import { factItems, factsLine, isOffMarket, money, statusLine, whenOf } from "../generate.ts";
99 import { publicOrigin } from "../env.ts";
10 import { permit } from "../contract.ts";
1011 import { escape } from "../http.ts";
1112 import { altOf, propertyPath, publicDescription, type Launched } from "../listing.ts";
1213 import type { Who } from "../who.ts";
@@ -14,6 +15,17 @@ import { monthlyPayment, PAYMENT_NOTE } from "../payment.ts";
1415 import { layout } from "./layout.ts";
1516 import { photoUrl } from "./listing.ts";
1617
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}`,
outdated

Role compared by hand. Use permit(who, "listing", "update", { ...l }) === "yes" for the back link.

23 palette: l.palette,
24 ...(permit(who, "listing", "update", { ...l }) === "yes"
25 ? { back: `/listings/${l.id}` }
26 : {}),
27 });
28
1729 /** Where a withdrawn or expired listing's address leads: the address and the agent, and no price, words or open house. */
1830 function offMarketPage(who: Who, l: Launched): string {
1931 return layout(
@@ -28,6 +40,7 @@ function offMarketPage(who: Who, l: Launched): string {
2840 who,
2941 {
3042 path: propertyPath(l),
43 agent: faceOf(who, l),
3144 heading: l.address,
3245 shareTitle: l.address,
3346 description: "This home is no longer available.",
@@ -83,11 +96,11 @@ export function propertyPage(input: { who: Who; listing: Launched }): string {
8396 ${words === null ? "" : `<p class="home-words">${escape(words).replace(/\n/g, "<br>")}</p>`}
8497 ${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>`}
8598 ${agentFooter(l)}
86 <p class="muted"><small>Equal Housing Opportunity.</small></p>
8799 </article>`,
88100 who,
89101 {
90102 path: propertyPath(l),
103 agent: faceOf(who, l),
91104 heading: l.address,
92105 shareTitle: `${l.address} · ${money(l.price)}`,
93106 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-")
1212 const base = `http://${server.hostname}:${server.port}`;
1313 afterAll(() => server.stop(true));
1414
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}`;
1717 return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
1818 };
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) });
2121
2222 const form = new FormData();
2323 for (const [k, v] of Object.entries({
@@ -223,3 +223,40 @@ test("a $0 price shows no estimated payment on the page", () => {
223223 expect(html).not.toContain("Est. $");
224224 expect(html).not.toContain("loan offer");
225225 });
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 {
14341434 display: inline-block;
14351435 min-width: 24ch;
14361436 }
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(--text-lg, 1.125rem);
1445 }
1446 .public-back {
1447 font-size: var(--text-sm);
1448 }
1449 .public-foot {
1450 text-align: center;
1451 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs