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
Check · Review · Land · Live — Its check is red.
mason/listing-launch:mason/137-one-link-for-the-agent-s-bio-and-email-s → main 2 commits fork
11 files
changed +307
−27
README.md +7
| @@ -169,6 +169,13 @@ to make a listing of your own. | ||
| 169 | 169 | `FORGE_APP_URL`, never whatever address the visitor typed; the terminal and the |
| 170 | 170 | launch kit print the same URL. Two samples' MLS texts (Alder Lane and 1630 Hawthorne Terrace) are approved so |
| 171 | 171 | 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". | |
| 172 | 179 | 14. **Print the seller report.** Press **Seller report** beside the flyer and |
| 173 | 180 | the kit: one page with the address, price, status, photo, the property page |
| 174 | 181 | 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> => | ||
| 37 | 37 | expect(res.status).toBe(303); |
| 38 | 38 | })()); |
| 39 | 39 | |
| 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 | ||
| 40 | 45 | /** Every page worth checking: the three named, then each sample listing's pages. */ |
| 41 | 46 | async function paths(): Promise<string[]> { |
| 42 | 47 | await flagOne(); |
| @@ -48,6 +53,7 @@ async function paths(): Promise<string[]> { | ||
| 48 | 53 | "/notes", |
| 49 | 54 | "/fair-housing", |
| 50 | 55 | "/week", |
| 56 | ...(await agentPaths()), | |
| 51 | 57 | ...ids.flatMap((id) => [ |
| 52 | 58 | `/listings/${id}`, |
| 53 | 59 | `/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 () => { | ||
| 74 | 74 | expect(approve.status).toBe(303); |
| 75 | 75 | }); |
| 76 | 76 | |
| 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 | ||
| 77 | 81 | const floor = (r: Response) => { |
| 78 | 82 | expect(r.headers.get("content-security-policy")).toBeTruthy(); |
| 79 | 83 | expect(r.headers.get("x-content-type-options")).toBe("nosniff"); |
| @@ -93,9 +97,9 @@ const expectInert = (html: string) => { | ||
| 93 | 97 | expect(html).not.toMatch(/<textarea[^>]*>(?:(?!<\/textarea>)[^])*<\/textarea>[^]*?<script>alert/); |
| 94 | 98 | }; |
| 95 | 99 | |
| 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"]) { | |
| 97 | 101 | 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())); | |
| 99 | 103 | expect(r.status).toBe(200); |
| 100 | 104 | floor(r); |
| 101 | 105 | const html = await r.text(); |
| @@ -120,7 +124,7 @@ test("the tablet screen's 422 re-render escapes the typed name", async () => { | ||
| 120 | 124 | }); |
| 121 | 125 | |
| 122 | 126 | 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()}`]) { | |
| 124 | 128 | const html = await (await call(path)).text(); |
| 125 | 129 | const metas = [...html.matchAll(/<meta\b[^>]*>/g)].map((m) => m[0]); |
| 126 | 130 | expect(metas.length).toBeGreaterThan(0); |
src/morehomes.ts +36 −22
| @@ -18,30 +18,44 @@ export interface MoreHome { | ||
| 18 | 18 | readonly heroAlt: string | null; |
| 19 | 19 | } |
| 20 | 20 | |
| 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 | ||
| 21 | 51 | /** Up to three other active or coming-soon homes by the listing's author, newest first. */ |
| 22 | 52 | 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} | |
| 31 | 57 | ORDER BY l.created_at DESC, l.id DESC LIMIT ?3`, |
| 32 | 58 | ) |
| 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); | |
| 47 | 61 | } |
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 { | ||
| 21 | 21 | TONES, |
| 22 | 22 | } from "../generate.ts"; |
| 23 | 23 | import { asset as assetHref, escape } from "../http.ts"; |
| 24 | import { agentPath } from "../agenthomes.ts"; | |
| 24 | 25 | import { altOf, approvalLabel, ASSET_MAX, propertyPath, type Asset, type Launched, type Listing } from "../listing.ts"; |
| 25 | 26 | import { countLine, MLS_LIMIT, isMls, isText, overNote } from "../postlength.ts"; |
| 26 | 27 | import { isSample } from "../photos.ts"; |
| @@ -279,6 +280,11 @@ const propertyLink = (l: Launched, origin: string): string => | ||
| 279 | 280 | `<p class="row property-link"><span>Property page</span> <a href="${propertyPath(l)}" data-property-url>${escape(propertyPath(l, origin))}</a> |
| 280 | 281 | <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>`; |
| 281 | 282 | |
| 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 | ||
| 282 | 288 | export function listingPage(input: { |
| 283 | 289 | who: Who; |
| 284 | 290 | listing: Launched; |
| @@ -340,6 +346,7 @@ export function listingPage(input: { | ||
| 340 | 346 | </header> |
| 341 | 347 | ${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>`} |
| 342 | 348 | ${propertyLink(l, origin)} |
| 349 | ${agentLink(l, origin)} | |
| 343 | 350 | ${phraseSummary(l, editable)} |
| 344 | 351 | ${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>`} |
| 345 | 352 | <div class="tabs" data-tabs> |
src/pages/property.ts +2 −1
| @@ -13,6 +13,7 @@ import { altOf, propertyPath, publicDescription, type Launched } from "../listin | ||
| 13 | 13 | import type { Who } from "../who.ts"; |
| 14 | 14 | import { monthlyPayment, PAYMENT_NOTE } from "../payment.ts"; |
| 15 | 15 | import type { MoreHome } from "../morehomes.ts"; |
| 16 | import { agentPath } from "../agenthomes.ts"; | |
| 16 | 17 | import { inquiryLinks } from "../inquiry.ts"; |
| 17 | 18 | import { layout } from "./layout.ts"; |
| 18 | 19 | import { photoUrl } from "./listing.ts"; |
| @@ -81,7 +82,7 @@ function moreSection(l: Launched, homes: readonly MoreHome[]): string { | ||
| 81 | 82 | const card = (h: MoreHome) => `<li class="card more-home"><a href="/homes/${h.id}">${ |
| 82 | 83 | h.hero === null ? "" : `<img src="${escape(photoUrl(h, h.hero))}" alt="${escape(h.heroAlt ?? h.address)}" width="240" height="160" loading="lazy">` |
| 83 | 84 | }<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>`; | |
| 85 | 86 | } |
| 86 | 87 | |
| 87 | 88 | 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"; | ||
| 82 | 82 | import { INTERESTS, SHOWING_AGENT_MAX, SHOWING_FEEDBACK_MAX } from "./showings.ts"; |
| 83 | 83 | import { emailPage } from "./pages/email.ts"; |
| 84 | 84 | import { publicOrigin } from "./env.ts"; |
| 85 | import { agentHomes } from "./agenthomes.ts"; | |
| 86 | import { agentHomesPage } from "./pages/agenthomes.ts"; | |
| 85 | 87 | import { moreHomes } from "./morehomes.ts"; |
| 86 | 88 | import { propertyPage } from "./pages/property.ts"; |
| 87 | 89 | import { countLine } from "./postlength.ts"; |
| @@ -629,6 +631,16 @@ export const routes: readonly Route[] = [ | ||
| 629 | 631 | 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) })); |
| 630 | 632 | }, |
| 631 | 633 | }, |
| 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 | }, | |
| 632 | 644 | { |
| 633 | 645 | method: "GET", |
| 634 | 646 | path: "/listings/:id/flyer", |
static/listing.js +1 −1
| @@ -75,7 +75,7 @@ for (const button of document.querySelectorAll("[data-copy-link]")) { | ||
| 75 | 75 | button.addEventListener("click", async () => { |
| 76 | 76 | await navigator.clipboard.writeText(button.dataset.copyLink); |
| 77 | 77 | button.textContent = "Copied"; |
| 78 | announce("Copied the property page link"); | |
| 78 | announce("Copied the link"); | |
| 79 | 79 | setTimeout(() => (button.textContent = "Copy"), 1500); |
| 80 | 80 | }); |
| 81 | 81 | } |