Public: anyone can read this; the crew can change it.

One link for the agent's bio and email signature: a public page listing every home the agent has for sale, in their brand #139

opened this

Check · Review · Land · Live — Its check is red. Next: builder, its check is red.

Ready to land

mason/listing-launch:mason/137-one-link-for-the-agent-s-bio-and-email-s → main 2 commits fork

  • Ready to merge — no conflicts with main. 2 commits will land on main.

    Check faileddeploy 12379f1

    Website spec: 3 findings, 4 the edge owns (report only)
    • / no <link rel="canonical"> — canonical
    • / missing og:image — open-graph
    • / a 13800-byte body was sent without br or gzip though Accept-Encoding: br, gzip — https://specification.website/ (the edge owns this)
    • / http://127.0.0.1:33057/tokens.css?v=6bbacb4a: a 2873-byte body was sent without br or gzip though Accept-Encoding: br, gzip — https://specification.website/ (the edge owns this)
    • / http://127.0.0.1:33057/static/app.css?v=882a108b: a 31134-byte body was sent without br or gzip though Accept-Encoding: br, gzip — https://specification.website/ (the edge owns this)
    • / no Cross-Origin-Opener-Policy — coop (the edge owns this)
    • / no ETag on the HTML — caching
    You need write access to merge.

Review

Sign in to review this change.
11 files changed +307 −27
README.md +7
@@ -169,6 +169,13 @@ to make a listing of your own.
169169 `FORGE_APP_URL`, never whatever address the visitor typed; the terminal and the
170170 launch kit print the same URL. Two samples' MLS texts (Alder Lane and 1630 Hawthorne Terrace) are approved so
171171 the preview shows a page, with each linking the other under "More homes from".
172 **One link for your bio.** Under it, "Your page of homes" (`/agents/:slug`)
173 has its own Copy button: paste it in an Instagram bio or email signature. It
174 lists every active and coming-soon home of yours whose page is public, in
175 your colours, with no tool bar or footer. The slug is your name plus a
176 short digest of your account, name and license, so it never exposes an id.
177 See Morgan Reyes's two sample homes by opening the Alder Lane listing and
178 following "Your page of homes".
172179 14. **Print the seller report.** Press **Seller report** beside the flyer and
173180 the kit: one page with the address, price, status, photo, the property page
174181 link and a row per asset, each with "Approved by … on …" and its words, or
src/a11y.test.ts +6
@@ -37,6 +37,11 @@ const flagOne = (): Promise<void> =>
3737 expect(res.status).toBe(303);
3838 })());
3939
40 /** The sample agent's public page, as the first listing links it. */
41 const agentPaths = async (): Promise<string[]> => [
42 /href="(\/agents\/[^"]+)"/.exec(await page("/listings/1"))![1]!,
43 ];
44
4045 /** Every page worth checking: the three named, then each sample listing's pages. */
4146 async function paths(): Promise<string[]> {
4247 await flagOne();
@@ -48,6 +53,7 @@ async function paths(): Promise<string[]> {
4853 "/notes",
4954 "/fair-housing",
5055 "/week",
56 ...(await agentPaths()),
5157 ...ids.flatMap((id) => [
5258 `/listings/${id}`,
5359 `/listings/${id}?tab=social`,
src/agenthomes.test.ts added +108
@@ -0,0 +1,108 @@
1 /** The agent's page: every public home of one agent, in their brand, and no one else's. */
2 import { afterAll, expect, test } from "bun:test";
3 import { mkdtempSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { agentSlug } from "./agenthomes.ts";
7 import { serve } from "./server.ts";
8 import { sign } from "./who.ts";
9
10 const KEY = "a-key-only-the-forge-holds";
11 const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "agenthomes-")), identityKey: KEY });
12 const base = `http://${server.hostname}:${server.port}`;
13 afterAll(() => server.stop(true));
14
15 const as = (id: string) => {
16 const identity = `${id}|${id}|user|${Date.now() + 600_000}`;
17 return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
18 };
19 const call = (path: string, who?: string, init: RequestInit = {}) =>
20 fetch(base + path, { redirect: "manual", ...init, headers: who === undefined ? {} : as(who) });
21
22 const launch = async (who: string, address: string, agent = "Pat Agent", license = "OR-1"): Promise<number> => {
23 const form = new FormData();
24 for (const [k, v] of Object.entries({
25 address, beds: 3, baths: 2, sqft: 1800, year: 1999, price: 500000, tone: "classic", palette: "teal",
26 agent_name: agent, brokerage: "Harbor Realty", license, agent_phone: "+1 (503) 555-0100",
27 agent_email: "[email protected]", lot: "0.2 acre", features: "Sunny kitchen\nQuiet street\nNew roof\nBig deck\nOak floors",
28 }))
29 form.set(k, String(v));
30 const r = await call("/listings", who, { method: "POST", body: form });
31 return Number(/\/listings\/(\d+)/.exec(r.headers.get("location")!)![1]);
32 };
33 const approve = async (who: string, id: number): Promise<void> => {
34 const page = await (await call(`/listings/${id}`, who)).text();
35 const seen = /name="seen" value="([^"]+)"/.exec(page)![1]!;
36 await call(`/listings/${id}/assets/mls/approve`, who, { method: "POST", body: new URLSearchParams({ approved: "1", seen }) });
37 };
38 const status = (who: string, id: number, to: string) =>
39 call(`/listings/${id}/status`, who, { method: "POST", body: new URLSearchParams({ status: to }) });
40
41 const slugOf = async (id: number): Promise<string> =>
42 /href="\/agents\/([^"]+)"/.exec(await (await call(`/listings/${id}`, "pat")).text())![1]!;
43
44 let slug = "";
45 let a = 0, b = 0, sold = 0, draft = 0, other = 0;
46 test("setup: two active homes, a sold one, an unapproved one, and another agent's", async () => {
47 a = await launch("pat", "1 Alpha Way, Portland, OR 97201");
48 b = await launch("pat", "2 Beta Way, Portland, OR 97201");
49 sold = await launch("pat", "3 Gamma Way, Portland, OR 97201");
50 draft = await launch("pat", "4 Delta Way, Portland, OR 97201");
51 other = await launch("sam", "5 Other Way, Portland, OR 97201", "Sam Other", "OR-2");
52 for (const [who, id] of [["pat", a], ["pat", b], ["pat", sold], ["sam", other]] as const) await approve(who, id);
53 expect((await status("pat", sold, "sold")).status).toBe(303);
54 slug = await slugOf(a);
55 });
56
57 test("the page needs no sign-in and lists exactly the agent's homes for sale", async () => {
58 const r = await call(`/agents/${slug}`);
59 expect(r.status).toBe(200);
60 const html = await r.text();
61 expect(html).toContain("1 Alpha Way");
62 expect(html).toContain("2 Beta Way");
63 for (const hidden of ["3 Gamma Way", "4 Delta Way", "5 Other Way"]) expect(html).not.toContain(hidden);
64 expect(html).toContain(`href="/homes/${a}"`);
65 expect(html).toContain(`href="/homes/${b}"`);
66 expect(html).toContain("$500,000");
67 expect(html).toContain("3 bd · 2 ba");
68 });
69
70 test("it speaks in the agent's brand, with no tool header or footer", async () => {
71 const html = await (await call(`/agents/${slug}`)).text();
72 for (const s of ["Pat Agent", "Harbor Realty", "OR-1", "+1 (503) 555-0100", "[email protected]", "palette-teal", "og:title", "og:description"])
73 expect(html).toContain(s);
74 expect(html).not.toContain("Terminal:");
75 expect(html).not.toContain('class="brandmark"');
76 expect(html).not.toContain("Sign in");
77 });
78
79 test("the slug hides the account and the page names nothing of another agent", async () => {
80 expect(slug).not.toContain("pat-agent-pat");
81 expect(slug).toBe(agentSlug({ author_id: "pat", agent_name: "Pat Agent", license: "OR-1" }));
82 const html = await (await call(`/agents/${slug}`)).text();
83 expect(html).not.toContain("Sam Other");
84 expect((await call(`/agents/${await slugOf(other).catch(() => "none")}`)).status).toBeLessThan(500);
85 });
86
87 test("an unknown, malformed or emptied page is a 404", async () => {
88 expect((await call("/agents/nobody-00000000")).status).toBe(404);
89 expect((await call("/agents/%3Cscript%3E")).status).toBe(404);
90 await status("pat", a, "withdrawn");
91 await status("pat", b, "sold");
92 expect((await call(`/agents/${slug}`)).status).toBe(404);
93 });
94
95 test("the listing page shows the link with a copy button, and the property page links to it", async () => {
96 const mine = await (await call(`/listings/${other}`, "sam")).text();
97 expect(mine).toContain("Your page of homes");
98 expect(mine).toContain("data-copy-link");
99 });
100
101 test("the sample agent has a page with two homes, linked from a property page", async () => {
102 const home = await (await call("/homes/1")).text();
103 const href = /href="(\/agents\/[^"]+)"/.exec(home)![1]!;
104 const html = await (await call(href)).text();
105 expect(html).toContain("412 Alder Lane");
106 expect(html).toContain("1630 Hawthorne Terrace");
107 expect(html).not.toContain("88 Meridian");
108 });
src/agenthomes.ts added +75
@@ -0,0 +1,75 @@
1 /**
2 * One agent's page of homes for sale, with no account model behind it. An
3 * agent is who the listings say they are: the account that launched them, the
4 * name and the license typed on them. The page's slug is that name, readable,
5 * and a short digest of the three, so it is stable, cannot be guessed from a
6 * listing id, and never carries the account id itself.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import { HOME_COLUMNS, homeOf, PUBLIC_HOME, type HomeRow, type MoreHome } from "./morehomes.ts";
11
12 export interface AgentKey {
13 readonly author_id: string;
14 readonly agent_name: string;
15 readonly license: string;
16 }
17
18 export interface AgentHomes {
19 readonly name: string;
20 readonly brokerage: string;
21 readonly license: string;
22 readonly phone: string;
23 readonly email: string;
24 readonly palette: string;
25 readonly headshot: string | null;
26 /** The newest listing, whose id addresses the headshot file. */
27 readonly faceId: number;
28 readonly homes: MoreHome[];
29 }
30
31 const words = (name: string): string =>
32 name.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
33
34 /** The agent's page slug: their name, then eight hex digits of a digest of who they are. */
35 export function agentSlug(k: AgentKey): string {
36 const digest = new Bun.CryptoHasher("sha256")
37 .update(`${k.author_id}\n${k.agent_name.trim().toLowerCase()}\n${k.license.trim().toLowerCase()}`)
38 .digest("hex")
39 .slice(0, 8);
40 return `${words(k.agent_name) || "agent"}-${digest}`;
41 }
42
43 export const agentPath = (k: AgentKey, origin = ""): string => `${origin}/agents/${agentSlug(k)}`;
44
45 /** The agent with this slug and every public home they have for sale, newest first; undefined when no one matches. */
46 export function agentHomes(db: Database, slug: string): AgentHomes | undefined {
47 if (!/^[a-z0-9-]{10,60}$/.test(slug)) return undefined;
48 const keys = db
49 .query<AgentKey, []>(
50 `SELECT DISTINCT l.author_id, l.agent_name, l.license FROM listing l WHERE ${PUBLIC_HOME}`,
51 )
52 .all();
53 const key = keys.find((k) => agentSlug(k) === slug);
54 if (key === undefined) return undefined;
55 const rows = db
56 .query<HomeRow & { brokerage: string; agent_phone: string; agent_email: string; palette: string; headshot: string | null }, [string, string, string]>(
57 `SELECT ${HOME_COLUMNS}, l.brokerage, l.agent_phone, l.agent_email, l.palette, l.headshot FROM listing l
58 WHERE l.author_id = ?1 AND l.agent_name = ?2 AND l.license = ?3 AND ${PUBLIC_HOME}
59 ORDER BY l.created_at DESC, l.id DESC`,
60 )
61 .all(key.author_id, key.agent_name, key.license);
62 const newest = rows[0];
63 if (newest === undefined) return undefined;
64 return {
65 name: key.agent_name,
66 brokerage: newest.brokerage,
67 license: key.license,
68 phone: newest.agent_phone,
69 email: newest.agent_email,
70 palette: newest.palette,
71 headshot: newest.headshot,
72 faceId: newest.id,
73 homes: rows.map(homeOf),
74 };
75 }
src/hostile.test.ts +7 −3
@@ -74,6 +74,10 @@ test("the hostile listing launches and its assets are edited", async () => {
7474 expect(approve.status).toBe(303);
7575 });
7676
77 /** The hostile agent's page address, as the property page links it. */
78 const agentSlugOf = async (): Promise<string> =>
79 /href="\/agents\/([^"]+)"/.exec(await (await call(`/listings/${id}`)).text())![1]!;
80
7781 const floor = (r: Response) => {
7882 expect(r.headers.get("content-security-policy")).toBeTruthy();
7983 expect(r.headers.get("x-content-type-options")).toBe("nosniff");
@@ -93,9 +97,9 @@ const expectInert = (html: string) => {
9397 expect(html).not.toMatch(/<textarea[^>]*>(?:(?!<\/textarea>)[^])*<\/textarea>[^]*?<script>alert/);
9498 };
9599
96 for (const path of ["/", "/listings/:id", "/listings/:id/flyer", "/listings/:id/postcard", "/listings/:id/card", "/listings/:id/report", "/listings/:id/schedule", "/week", "/listings/:id/sign-in", "/listings/:id/open-house", "/listings/:id/email", "/homes/:id"]) {
100 for (const path of ["/", "/listings/:id", "/listings/:id/flyer", "/listings/:id/postcard", "/listings/:id/card", "/listings/:id/report", "/listings/:id/schedule", "/week", "/listings/:id/sign-in", "/listings/:id/open-house", "/listings/:id/email", "/homes/:id", "/agents/:slug"]) {
97101 test(`${path} escapes every typed word`, async () => {
98 const r = await call(path.replace(":id", String(id)));
102 const r = await call(path.replace(":id", String(id)).replace(":slug", await agentSlugOf()));
99103 expect(r.status).toBe(200);
100104 floor(r);
101105 const html = await r.text();
@@ -120,7 +124,7 @@ test("the tablet screen's 422 re-render escapes the typed name", async () => {
120124 });
121125
122126 test("share and Open Graph tags escape the listing's words", async () => {
123 for (const path of [`/listings/${id}`, `/listings/${id}/email`, `/listings/${id}/flyer`, `/listings/${id}/postcard`, `/homes/${id}`]) {
127 for (const path of [`/listings/${id}`, `/listings/${id}/email`, `/listings/${id}/flyer`, `/listings/${id}/postcard`, `/homes/${id}`, `/agents/${await agentSlugOf()}`]) {
124128 const html = await (await call(path)).text();
125129 const metas = [...html.matchAll(/<meta\b[^>]*>/g)].map((m) => m[0]);
126130 expect(metas.length).toBeGreaterThan(0);
src/morehomes.ts +36 −22
@@ -18,30 +18,44 @@ export interface MoreHome {
1818 readonly heroAlt: string | null;
1919 }
2020
21 export interface HomeRow {
22 readonly id: number;
23 readonly address: string;
24 readonly price: number;
25 readonly beds: number;
26 readonly baths: number;
27 readonly photos: string;
28 readonly photo_alt: string;
29 }
30
31 /** The columns a card of a home needs, and the rule that its page is public: an approved MLS description. */
32 export const HOME_COLUMNS = "l.id, l.address, l.price, l.beds, l.baths, l.photos, l.photo_alt";
33 export const PUBLIC_HOME = `l.status IN ('active', 'coming_soon')
34 AND EXISTS (SELECT 1 FROM asset a WHERE a.listing_id = l.id AND a.kind = 'mls' AND a.approved_at IS NOT NULL)`;
35
36 /** A card's facts and hero (with its alt text) from a stored row. */
37 export function homeOf(r: HomeRow): MoreHome {
38 const hero = (JSON.parse(r.photos) as string[])[0] ?? null;
39 const alt = JSON.parse(r.photo_alt) as Record<string, string>;
40 return {
41 id: r.id,
42 address: r.address,
43 price: r.price,
44 beds: r.beds,
45 baths: r.baths,
46 hero,
47 heroAlt: hero === null ? null : (alt[hero] ?? r.address),
48 };
49 }
50
2151 /** Up to three other active or coming-soon homes by the listing's author, newest first. */
2252 export function moreHomes(db: Database, listing: { id: number; author_id: string }): MoreHome[] {
23 const rows = db
24 .query<
25 { id: number; address: string; price: number; beds: number; baths: number; photos: string; photo_alt: string },
26 [string, number, number]
27 >(
28 `SELECT l.id, l.address, l.price, l.beds, l.baths, l.photos, l.photo_alt FROM listing l
29 WHERE l.author_id = ?1 AND l.id != ?2 AND l.status IN ('active', 'coming_soon')
30 AND EXISTS (SELECT 1 FROM asset a WHERE a.listing_id = l.id AND a.kind = 'mls' AND a.approved_at IS NOT NULL)
53 return db
54 .query<HomeRow, [string, number, number]>(
55 `SELECT ${HOME_COLUMNS} FROM listing l
56 WHERE l.author_id = ?1 AND l.id != ?2 AND ${PUBLIC_HOME}
3157 ORDER BY l.created_at DESC, l.id DESC LIMIT ?3`,
3258 )
33 .all(listing.author_id, listing.id, MORE_HOMES_MAX);
34 return rows.map((r) => {
35 const hero = (JSON.parse(r.photos) as string[])[0] ?? null;
36 const alt = JSON.parse(r.photo_alt) as Record<string, string>;
37 return {
38 id: r.id,
39 address: r.address,
40 price: r.price,
41 beds: r.beds,
42 baths: r.baths,
43 hero,
44 heroAlt: hero === null ? null : (alt[hero] ?? r.address),
45 };
46 });
59 .all(listing.author_id, listing.id, MORE_HOMES_MAX)
60 .map(homeOf);
4761 }
src/pages/agenthomes.ts added +46
@@ -0,0 +1,46 @@
1 /**
2 * An agent's page for a bio or an email signature, at `/agents/:slug`: every
3 * home they have for sale, in their brand, with no tool header or footer.
4 * Only facts and each home's hero photo leave here.
5 */
6
7 import type { AgentHomes } from "../agenthomes.ts";
8 import { money } from "../generate.ts";
9 import { publicOrigin } from "../env.ts";
10 import { escape } from "../http.ts";
11 import type { Who } from "../who.ts";
12 import { layout } from "./layout.ts";
13 import { photoUrl } from "./listing.ts";
14
15 export function agentHomesPage(input: { who: Who; agent: AgentHomes; path: string }): string {
16 const { who, agent: a, path } = input;
17 const card = (h: AgentHomes["homes"][number]) => `<li class="card more-home"><a href="/homes/${h.id}">${
18 h.hero === null ? "" : `<img src="${escape(photoUrl(h, h.hero))}" alt="${escape(h.heroAlt ?? h.address)}" width="480" height="320" loading="lazy">`
19 }<span class="more-address">${escape(h.address)}</span><span class="more-price brand">${money(h.price)}</span><span class="muted">${escape(String(h.beds))} bd · ${escape(String(h.baths))} ba</span></a></li>`;
20 const first = a.homes.find((h) => h.hero !== null);
21 const count = `${a.homes.length} ${a.homes.length === 1 ? "home" : "homes"} for sale`;
22 return layout(
23 `<article class="home palette-${escape(a.palette)}">
24 <header class="home-head agent-head">
25 ${a.headshot === null ? "" : `<img class="home-face" src="${escape(photoUrl({ id: a.faceId }, a.headshot))}" alt="Photo of ${escape(a.name)}" width="72" height="72">`}
26 <div>
27 <h1>Homes from ${escape(a.name)}</h1>
28 <p>${escape(a.brokerage)} · License ${escape(a.license)}</p>
29 <p><a href="tel:${escape(a.phone.replace(/[^0-9+]/g, ""))}">${escape(a.phone)}</a> · <a href="mailto:${escape(a.email)}">${escape(a.email)}</a></p>
30 </div>
31 </header>
32 <section class="more-homes" aria-labelledby="agent-homes-h"><h2 id="agent-homes-h">${count}</h2><ul>${a.homes.map(card).join("")}</ul></section>
33 </article>`,
34 who,
35 {
36 path,
37 agent: { name: a.name, brokerage: a.brokerage, contact: `${a.phone} · ${a.email}`, palette: a.palette },
38 heading: `Homes from ${a.name}`,
39 shareTitle: `Homes from ${a.name} · ${a.brokerage}`,
40 description: `${count}, from ${a.name} at ${a.brokerage}.`,
41 ...(first?.hero == null
42 ? {}
43 : { image: { url: publicOrigin + photoUrl(first, first.hero), alt: first.heroAlt ?? first.address } }),
44 },
45 );
46 }
src/pages/listing.ts +7
@@ -21,6 +21,7 @@ import {
2121 TONES,
2222 } from "../generate.ts";
2323 import { asset as assetHref, escape } from "../http.ts";
24 import { agentPath } from "../agenthomes.ts";
2425 import { altOf, approvalLabel, ASSET_MAX, propertyPath, type Asset, type Launched, type Listing } from "../listing.ts";
2526 import { countLine, MLS_LIMIT, isMls, isText, overNote } from "../postlength.ts";
2627 import { isSample } from "../photos.ts";
@@ -279,6 +280,11 @@ const propertyLink = (l: Launched, origin: string): string =>
279280 `<p class="row property-link"><span>Property page</span> <a href="${propertyPath(l)}" data-property-url>${escape(propertyPath(l, origin))}</a>
280281 <button type="button" class="button button-quiet" data-copy-link="${escape(propertyPath(l, origin))}" hidden aria-label="Copy the property page link">Copy</button></p>`;
281282
283 /** The agent's page of every home they have for sale, for a bio or a signature, with a Copy button. */
284 const agentLink = (l: Launched, origin: string): string =>
285 `<p class="row property-link"><span>Your page of homes</span> <a href="${escape(agentPath(l))}">${escape(agentPath(l, origin))}</a>
286 <button type="button" class="button button-quiet" data-copy-link="${escape(agentPath(l, origin))}" hidden aria-label="Copy the link to your page of homes">Copy</button></p>`;
287
282288 export function listingPage(input: {
283289 who: Who;
284290 listing: Launched;
@@ -340,6 +346,7 @@ export function listingPage(input: {
340346 </header>
341347 ${editable ? "" : `<p class="card read-only">You are reading ${escape(l.author_name === "Sample listing" ? "the sample listing" : `${l.author_name}'s listing`)}. <a href="/?from=${l.id}">Start a new one from it</a> to make the words your own.</p>`}
342348 ${propertyLink(l, origin)}
349 ${agentLink(l, origin)}
343350 ${phraseSummary(l, editable)}
344351 ${l.photos.length === 0 ? "" : `<ul class="strip">${l.photos.map((p, i) => `<li><img src="${escape(photoUrl(l, p))}" alt="${escape(altOf(l, p, `Photo ${i + 1} of ${l.address}`))}" width="240" height="160" loading="${i < 3 ? "eager" : "lazy"}"></li>`).join("")}</ul>`}
345352 <div class="tabs" data-tabs>
src/pages/property.ts +2 −1
@@ -13,6 +13,7 @@ import { altOf, propertyPath, publicDescription, type Launched } from "../listin
1313 import type { Who } from "../who.ts";
1414 import { monthlyPayment, PAYMENT_NOTE } from "../payment.ts";
1515 import type { MoreHome } from "../morehomes.ts";
16 import { agentPath } from "../agenthomes.ts";
1617 import { inquiryLinks } from "../inquiry.ts";
1718 import { layout } from "./layout.ts";
1819 import { photoUrl } from "./listing.ts";
@@ -81,7 +82,7 @@ function moreSection(l: Launched, homes: readonly MoreHome[]): string {
8182 const card = (h: MoreHome) => `<li class="card more-home"><a href="/homes/${h.id}">${
8283 h.hero === null ? "" : `<img src="${escape(photoUrl(h, h.hero))}" alt="${escape(h.heroAlt ?? h.address)}" width="240" height="160" loading="lazy">`
8384 }<span class="more-address">${escape(h.address)}</span><span class="more-price brand">${money(h.price)}</span><span class="muted">${escape(String(h.beds))} bd · ${escape(String(h.baths))} ba</span></a></li>`;
84 return `<section class="more-homes" aria-labelledby="more-homes-h"><h3 id="more-homes-h">More homes from ${escape(l.agent_name)}</h3><ul>${homes.map(card).join("")}</ul></section>`;
85 return `<section class="more-homes" aria-labelledby="more-homes-h"><h3 id="more-homes-h">More homes from ${escape(l.agent_name)}</h3><ul>${homes.map(card).join("")}</ul><p><a href="${escape(agentPath(l))}">See all homes from ${escape(l.agent_name)}</a></p></section>`;
8586 }
8687
8788 export function propertyPage(input: { who: Who; listing: Launched; more?: readonly MoreHome[] }): string {
src/routes.ts +12
@@ -82,6 +82,8 @@ import { factsPage } from "./pages/facts.ts";
8282 import { INTERESTS, SHOWING_AGENT_MAX, SHOWING_FEEDBACK_MAX } from "./showings.ts";
8383 import { emailPage } from "./pages/email.ts";
8484 import { publicOrigin } from "./env.ts";
85 import { agentHomes } from "./agenthomes.ts";
86 import { agentHomesPage } from "./pages/agenthomes.ts";
8587 import { moreHomes } from "./morehomes.ts";
8688 import { propertyPage } from "./pages/property.ts";
8789 import { countLine } from "./postlength.ts";
@@ -629,6 +631,16 @@ export const routes: readonly Route[] = [
629631 return page(c.who, getHome(c.db, c.who, { id: Number(c.params.id) }), (listing) => propertyPage({ who: c.who, listing, more: moreHomes(c.db, listing) }));
630632 },
631633 },
634 {
635 method: "GET",
636 path: "/agents/:slug",
637 handle: (c) => {
638 const agent = agentHomes(c.db, c.params.slug ?? "");
639 return agent === undefined
640 ? page(c.who, { ok: false, error: "not_found", message: "There is no such agent page." }, () => "")
641 : html(agentHomesPage({ who: c.who, agent, path: c.url.pathname }));
642 },
643 },
632644 {
633645 method: "GET",
634646 path: "/listings/:id/flyer",
static/listing.js +1 −1
@@ -75,7 +75,7 @@ for (const button of document.querySelectorAll("[data-copy-link]")) {
7575 button.addEventListener("click", async () => {
7676 await navigator.clipboard.writeText(button.dataset.copyLink);
7777 button.textContent = "Copied";
78 announce("Copied the property page link");
78 announce("Copied the link");
7979 setTimeout(() => (button.textContent = "Copy"), 1500);
8080 });
8181 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs