Public: anyone can read this; the crew can change it.
Give each listing a property page to link from the posts and the email, showing only approved words #50
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/47-give-each-listing-a-property-page-to-lin → main 3 commits fork 2 threads · 2 unresolved
20 files
changed +430
−20
README.md +9
| @@ -62,6 +62,15 @@ to make a listing of your own. | ||
| 62 | 62 | facts, then every asset under its name, each marked "Approved by … on …" |
| 63 | 63 | or "DRAFT — not approved", with its Fair Housing findings beneath it. Hand |
| 64 | 64 | it to an assistant or a coordinator. |
| 65 | 11. **Show buyers the home.** Under the facts on the listing, the property | |
| 66 | page link (`/homes/:id`) has a Copy button; the flyer prints it and the | |
| 67 | email's "See the home" button points to it. Open it on a phone: photos, | |
| 68 | price, status, open house, your contact details, and the MLS description | |
| 69 | only once you approved it. Until then everyone else gets a 404 and you see | |
| 70 | "Not public yet: approve the MLS description". The link's origin is | |
| 71 | `FORGE_APP_URL`, never whatever address the visitor typed; the terminal and the | |
| 72 | launch kit print the same URL. The first sample's MLS text is approved so | |
| 73 | the preview shows a page. | |
| 65 | 74 | |
| 66 | 75 | `src/walkthrough.test.ts` walks these steps on every `bun test`, so this |
| 67 | 76 | list cannot drift from the app. |
docs/design/areas/property.md added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | # Property page | |
| 2 | ||
| 3 | New component classes, all built from existing tokens: | |
| 4 | ||
| 5 | - `home` (`home-hero`, `home-head`, `home-price`, `home-facts`, `home-open`, `home-words`, `home-agent`, `home-face`, `home-name`, `home-notice`) — the buyer's page at `/homes/:id`. | |
| 6 | - `property-link` — the link and Copy button on the listing page. | |
| 7 | - `flyer-link` — the property page address printed on the flyer. | |
| 8 | - `email-cta`, `email-button` — the "See the home" button in the email. |
src/actions.ts +13
| @@ -19,6 +19,7 @@ import { | ||
| 19 | 19 | fromRow, |
| 20 | 20 | isKind, |
| 21 | 21 | isTone, |
| 22 | publicDescription, | |
| 22 | 23 | touchesOpenHouses, |
| 23 | 24 | validate, |
| 24 | 25 | validateStatus, |
| @@ -500,6 +501,18 @@ export const getListing = ( | ||
| 500 | 501 | no: "You may not see this listing.", |
| 501 | 502 | }); |
| 502 | 503 | |
| 504 | /** | |
| 505 | * A listing as its property page shows it: whoever may read it, once its MLS | |
| 506 | * description is approved; before that, only whoever may change it. Everyone | |
| 507 | * else is told there is no such home, as if there were none. | |
| 508 | */ | |
| 509 | export const getHome = (db: Database, who: Who, input: { id: number }): Result<Launched> => { | |
| 510 | const found = getListing(db, who, input); | |
| 511 | if (found.ok && publicDescription(found.value) === null && !canEditListing(who, found.value)) | |
| 512 | return fail("not_found", "There is no such home."); | |
| 513 | return found; | |
| 514 | }; | |
| 515 | ||
| 503 | 516 | /** The listing's launch kit as text: whoever may read the listing may read it. */ |
| 504 | 517 | export const launchKit = (db: Database, who: Who, input: { id: number }): Result<string> => { |
| 505 | 518 | const found = getListing(db, who, input); |
src/contract.test.ts +26
| @@ -15,6 +15,7 @@ import { | ||
| 15 | 15 | deleteNote, |
| 16 | 16 | editAsset, |
| 17 | 17 | getAudit, |
| 18 | getHome, | |
| 18 | 19 | getListing, |
| 19 | 20 | getNote, |
| 20 | 21 | insertListing, |
| @@ -248,3 +249,28 @@ test("changing a listing's photos answers as the listing's update cell says, at | ||
| 248 | 249 | } |
| 249 | 250 | expect(asked).toBeGreaterThan(0); |
| 250 | 251 | }); |
| 252 | ||
| 253 | /** The property page is `listing:read` once approved; before, only whoever may update the row sees it. */ | |
| 254 | test("a property page answers as the listing's read cell says once approved, and as its update cell before", () => { | |
| 255 | const admin: Who = { role: "admin", id: "root", name: "root" }; | |
| 256 | let asked = 0; | |
| 257 | for (const read of table(contract)) { | |
| 258 | if (read.resource !== "listing" || read.verb !== "read") continue; | |
| 259 | const who = whoIs(read.as); | |
| 260 | const { mine, theirs } = actingAs(contract, read.as); | |
| 261 | const update = table(contract).find((c) => c.resource === "listing" && c.verb === "update" && c.as === read.as)!; | |
| 262 | for (const [authorId, owns] of [[String(mine["author_id"]), true], [String(theirs["author_id"]), false]] as const) { | |
| 263 | const id = listingBy(authorId); | |
| 264 | const before = getHome(db, who, { id }).ok; | |
| 265 | const mayUpdate = update.answer === "yes" || (update.answer === "own" && owns); | |
| 266 | expect([read.as, authorId, "before", before]).toEqual([read.as, authorId, "before", mayUpdate]); | |
| 267 | const seen = getListing(db, admin, { id }); | |
| 268 | const updated = seen.ok ? seen.value.assets.find((a) => a.kind === "mls")!.updated_at : ""; | |
| 269 | approveAsset(db, admin, { id, kind: "mls", seen: updated, door: "page" }); | |
| 270 | const mayRead = read.answer === "yes" || (read.answer === "own" && owns); | |
| 271 | expect([read.as, authorId, "after", getHome(db, who, { id }).ok]).toEqual([read.as, authorId, "after", mayRead]); | |
| 272 | asked++; | |
| 273 | } | |
| 274 | } | |
| 275 | expect(asked).toBeGreaterThan(0); | |
| 276 | }); |
src/hostile.test.ts +9 −1
| @@ -61,6 +61,14 @@ test("the hostile listing launches and its assets are edited", async () => { | ||
| 61 | 61 | edit.set("text", PAYLOADS.asset); |
| 62 | 62 | expect((await call(`/listings/${id}/assets/${kind}`, { method: "POST", body: edit })).status).toBe(303); |
| 63 | 63 | } |
| 64 | // The property page shows the MLS words only once approved. | |
| 65 | const page = await (await call(`/listings/${id}`)).text(); | |
| 66 | const seen = /name="seen" value="([^"]+)"/.exec(page)![1]!; | |
| 67 | const approve = await call(`/listings/${id}/assets/mls/approve`, { | |
| 68 | method: "POST", | |
| 69 | body: new URLSearchParams({ approved: "1", seen }), | |
| 70 | }); | |
| 71 | expect(approve.status).toBe(303); | |
| 64 | 72 | }); |
| 65 | 73 | |
| 66 | 74 | const floor = (r: Response) => { |
| @@ -82,7 +90,7 @@ const expectInert = (html: string) => { | ||
| 82 | 90 | expect(html).not.toMatch(/<textarea[^>]*>(?:(?!<\/textarea>)[^])*<\/textarea>[^]*?<script>alert/); |
| 83 | 91 | }; |
| 84 | 92 | |
| 85 | for (const path of ["/", "/listings/:id", "/listings/:id/flyer", "/listings/:id/email"]) { | |
| 93 | for (const path of ["/", "/listings/:id", "/listings/:id/flyer", "/listings/:id/email", "/homes/:id"]) { | |
| 86 | 94 | test(`${path} escapes every typed word`, async () => { |
| 87 | 95 | const r = await call(path.replace(":id", String(id))); |
| 88 | 96 | expect(r.status).toBe(200); |
src/kit.ts +4 −2
| @@ -6,7 +6,8 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { factItems, money, openHouseLine } from "./generate.ts"; |
| 9 | import type { Asset, Launched } from "./listing.ts"; | |
| 9 | import { publicOrigin } from "./env.ts"; | |
| 10 | import { propertyLine, type Asset, type Launched } from "./listing.ts"; | |
| 10 | 11 | |
| 11 | 12 | // Control characters (keeping newline where a block may have lines) and the bidirectional overrides. |
| 12 | 13 | const CONTROLS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f--]/g; |
| @@ -42,7 +43,7 @@ const asset = (a: Asset): string => | ||
| 42 | 43 | ].join("\n"); |
| 43 | 44 | |
| 44 | 45 | /** The kit's text. */ |
| 45 | export function kit(l: Launched): string { | |
| 46 | export function kit(l: Launched, origin = publicOrigin): string { | |
| 46 | 47 | const open = openHouseLine(l); |
| 47 | 48 | const signed = l.assets.filter((a) => a.approval !== null).length; |
| 48 | 49 | return `${[ |
| @@ -51,6 +52,7 @@ export function kit(l: Launched): string { | ||
| 51 | 52 | factItems(l).map((i) => `${oneLine(String(i.value))} ${oneLine(i.label)}`).join(" · "), |
| 52 | 53 | ...(open === null ? [] : [oneLine(open)]), |
| 53 | 54 | `Agent: ${oneLine(l.agent_name)} · ${oneLine(l.brokerage)} · License ${oneLine(l.license)} · ${oneLine(l.agent_phone)} · ${oneLine(l.agent_email)}`, |
| 55 | propertyLine(l, origin), | |
| 54 | 56 | `${signed} of ${l.assets.length} assets approved. Send only what is marked approved.`, |
| 55 | 57 | "", |
| 56 | 58 | ...l.assets.flatMap((a) => [asset(a), ""]), |
src/listing.ts +14
| @@ -368,3 +368,17 @@ export function assetText(v: unknown): Checked<string> { | ||
| 368 | 368 | /** "Ready to send" once every asset is signed off, else how many are. */ |
| 369 | 369 | export const approvalLabel = (approved: number, total: number): string => |
| 370 | 370 | approved === total ? "Ready to send" : `${approved} of ${total} approved`; |
| 371 | ||
| 372 | /** Where buyers are sent: a listing's property page, absolute when an origin is given. */ | |
| 373 | export const propertyPath = (listing: Pick<Listing, "id">, origin = ""): string => | |
| 374 | `${origin}/homes/${listing.id}`; | |
| 375 | ||
| 376 | /** One plain-text line for the terminal and the kit: the property page's URL, and whether buyers can open it yet. */ | |
| 377 | export const propertyLine = (l: Launched, origin: string): string => | |
| 378 | `Property page: ${propertyPath(l, origin)}${l.assets.find((a) => a.kind === "mls")?.approval == null ? " (not public yet: approve the MLS description)" : ""}`; | |
| 379 | ||
| 380 | /** The MLS description as buyers may read it: its words once approved, else null. */ | |
| 381 | export const publicDescription = (l: Launched): string | null => { | |
| 382 | const mls = l.assets.find((a) => a.kind === "mls"); | |
| 383 | return mls === undefined || mls.approval === null ? null : mls.text; | |
| 384 | }; |
src/listings.test.ts +3 −1
| @@ -505,7 +505,9 @@ test("the email blast is a page of its own, in the agent's brand", async () => { | ||
| 505 | 505 | expect(page).toContain("data-subject>A home you will want to see: 18 Quarry Road<"); |
| 506 | 506 | expect(page.split(`>${SAMPLE.agent_name}<`).length - 1).toBe(1); |
| 507 | 507 | expect(page).not.toContain("The facts:"); |
| 508 | expect(page).not.toMatch(/(src|href)="https?:/); | |
| 508 | // Only the property page link is absolute: it must work from a mail tool. | |
| 509 | expect(page.replace(/href="https?:\/\/[^"/]+\/homes\/\d+"/, "")).not.toMatch(/(src|href)="https?:/); | |
| 510 | expect(page).toMatch(/class="email-button brand-fill" href="http:\/\/[^"/]+\/homes\/\d+"/); | |
| 509 | 511 | // The Email tab links to it. |
| 510 | 512 | expect(await (await fetch(base + to + "?tab=email")).text()).toContain(`href="${to}/email"`); |
| 511 | 513 | }); |
src/pages/email.ts +5 −4
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { factItems, money } from "../generate.ts"; |
| 9 | 9 | import { escape } from "../http.ts"; |
| 10 | import type { Launched } from "../listing.ts"; | |
| 10 | import { propertyPath, type Launched } from "../listing.ts"; | |
| 11 | 11 | import type { Who } from "../who.ts"; |
| 12 | 12 | import { layout } from "./layout.ts"; |
| 13 | 13 | import { draftBand, photoUrl } from "./listing.ts"; |
| @@ -31,7 +31,7 @@ export function emailParts( | ||
| 31 | 31 | } |
| 32 | 32 | |
| 33 | 33 | /** The message itself, one article: what is copied is what is shown. */ |
| 34 | export function email(l: Launched): string { | |
| 34 | export function email(l: Launched, origin = ""): string { | |
| 35 | 35 | const { subject, paragraphs } = emailParts( |
| 36 | 36 | l.assets.find((a) => a.kind === "email")?.text ?? "", |
| 37 | 37 | l.agent_name, |
| @@ -46,6 +46,7 @@ export function email(l: Launched): string { | ||
| 46 | 46 | <p class="email-price brand">${money(l.price)}</p> |
| 47 | 47 | <p class="email-facts brand-fill">${factItems(l).map((i) => `${escape(i.value)} ${i.label}`).join(" · ")}</p> |
| 48 | 48 | ${paragraphs.map((p) => `<p class="email-body">${escape(p).replace(/\n/g, "<br>")}</p>`).join("")} |
| 49 | <p class="email-cta"><a class="email-button brand-fill" href="${escape(propertyPath(l, origin))}">See the home</a></p> | |
| 49 | 50 | <div class="email-agent"> |
| 50 | 51 | ${l.headshot === null ? "" : `<img class="email-face" src="${escape(photoUrl(l, l.headshot))}" alt="Photo of ${escape(l.agent_name)}" width="72" height="72">`} |
| 51 | 52 | <div> |
| @@ -59,7 +60,7 @@ export function email(l: Launched): string { | ||
| 59 | 60 | </article>`; |
| 60 | 61 | } |
| 61 | 62 | |
| 62 | export function emailPage(input: { who: Who; listing: Launched }): string { | |
| 63 | export function emailPage(input: { who: Who; listing: Launched; origin?: string }): string { | |
| 63 | 64 | const { who, listing: l } = input; |
| 64 | 65 | return layout( |
| 65 | 66 | `<div class="print-bar row"> |
| @@ -68,7 +69,7 @@ export function emailPage(input: { who: Who; listing: Launched }): string { | ||
| 68 | 69 | <button type="button" class="button button-quiet" data-copy-subject hidden>Copy subject</button> |
| 69 | 70 | <span class="muted" data-copy-note role="status">Paste it into your mail tool; the photos load from this page.</span> |
| 70 | 71 | </div> |
| 71 | ${email(l)}`, | |
| 72 | ${email(l, input.origin)}`, | |
| 72 | 73 | who, |
| 73 | 74 | { path: `/listings/${l.id}/email`, heading: `Email: ${l.address}` }, |
| 74 | 75 | ); |
src/pages/listing.ts +15 −6
| @@ -16,7 +16,7 @@ import { | ||
| 16 | 16 | TONES, |
| 17 | 17 | } from "../generate.ts"; |
| 18 | 18 | import { asset as assetHref, escape } from "../http.ts"; |
| 19 | import { approvalLabel, ASSET_MAX, type Asset, type Launched, type Listing } from "../listing.ts"; | |
| 19 | import { approvalLabel, ASSET_MAX, propertyPath, type Asset, type Launched, type Listing } from "../listing.ts"; | |
| 20 | 20 | import { isPost, lengthLine, meantForX, overXNote } from "../postlength.ts"; |
| 21 | 21 | import { isSample } from "../photos.ts"; |
| 22 | 22 | import type { Who } from "../who.ts"; |
| @@ -97,7 +97,7 @@ export const statusPill = (l: Pick<Listing, "status" | "sold_price">): string => | ||
| 97 | 97 | ? "" |
| 98 | 98 | : `<span class="pill status-pill status-${l.status}">${escape(statusLine(l))}</span>`; |
| 99 | 99 | |
| 100 | const statusLine = (l: Pick<Listing, "status" | "sold_price">): string => | |
| 100 | export const statusLine = (l: Pick<Listing, "status" | "sold_price">): string => | |
| 101 | 101 | l.status === "sold" && l.sold_price !== null ? `Sold at ${money(l.sold_price)}` : STATUS_LABELS[l.status]; |
| 102 | 102 | |
| 103 | 103 | /** The agent's way to say where the listing stands: a choice, the price it sold at, one button. */ |
| @@ -166,14 +166,21 @@ const TABS: readonly { id: string; label: string; kinds: readonly string[] }[] = | ||
| 166 | 166 | export const tabOf = (name: string | null): string => |
| 167 | 167 | TABS.find((t) => t.id === name || t.kinds.includes(name ?? ""))?.id ?? "mls"; |
| 168 | 168 | |
| 169 | /** The property page link with a Copy button (the script shows it): what buyers get from posts and email. */ | |
| 170 | const propertyLink = (l: Launched, origin: string): string => | |
| 171 | `<p class="row property-link"><span>Property page</span> <a href="${propertyPath(l)}" data-property-url>${escape(propertyPath(l, origin))}</a> | |
| 172 | <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>`; | |
| 173 | ||
| 169 | 174 | export function listingPage(input: { |
| 170 | 175 | who: Who; |
| 171 | 176 | listing: Launched; |
| 177 | origin?: string; | |
| 172 | 178 | editable: boolean; |
| 173 | 179 | tab?: string | null; |
| 174 | 180 | notice?: string | undefined; |
| 175 | 181 | }): string { |
| 176 | 182 | const { who, listing: l, editable, notice } = input; |
| 183 | const origin = input.origin ?? ""; | |
| 177 | 184 | const open = tabOf(input.tab ?? null); |
| 178 | 185 | const n = flags(l); |
| 179 | 186 | const tabFlags = (kinds: readonly string[]) => |
| @@ -212,6 +219,7 @@ export function listingPage(input: { | ||
| 212 | 219 | </div> |
| 213 | 220 | </header> |
| 214 | 221 | ${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>`} |
| 222 | ${propertyLink(l, origin)} | |
| 215 | 223 | ${l.photos.length === 0 ? "" : `<ul class="strip">${l.photos.map((p, i) => `<li><img src="${escape(photoUrl(l, p))}" alt="Photo ${i + 1} of ${escape(l.address)}" width="240" height="160" loading="${i < 3 ? "eager" : "lazy"}"></li>`).join("")}</ul>`} |
| 216 | 224 | <div class="tabs" data-tabs> |
| 217 | 225 | <nav class="tablist" role="tablist" aria-label="Assets">${TABS.map( |
| @@ -219,7 +227,7 @@ export function listingPage(input: { | ||
| 219 | 227 | ).join("")}</nav> |
| 220 | 228 | ${TABS.map( |
| 221 | 229 | (t) => `<div class="tabpanel" role="tabpanel" id="panel-${t.id}" aria-labelledby="tab-${t.id}"${t.id === open ? "" : " hidden"}> |
| 222 | ${t.id === "flyer" ? `<div class="flyer-preview"><a href="/listings/${l.id}/flyer" aria-label="Open the printable flyer">${flyer(l)}</a></div>` : ""} | |
| 230 | ${t.id === "flyer" ? `<div class="flyer-preview"><a href="/listings/${l.id}/flyer" aria-label="Open the printable flyer">${flyer(l, origin)}</a></div>` : ""} | |
| 223 | 231 | ${t.id === "email" ? `<p><a class="button button-quiet" href="/listings/${l.id}/email">Preview email</a></p>` : ""} |
| 224 | 232 | ${l.assets |
| 225 | 233 | .filter((a) => t.kinds.includes(a.kind)) |
| @@ -243,7 +251,7 @@ export function listingPage(input: { | ||
| 243 | 251 | } |
| 244 | 252 | |
| 245 | 253 | /** The flyer: the showpiece, a letter-size page in the agent's colours. */ |
| 246 | export function flyer(l: Launched): string { | |
| 254 | export function flyer(l: Launched, origin = ""): string { | |
| 247 | 255 | const copy = flyerParts(l.assets.find((a) => a.kind === "flyer")?.text ?? "", openHouseLine(l)); |
| 248 | 256 | const [hero, ...more] = l.photos; |
| 249 | 257 | return `<article class="flyer palette-${l.palette}"> |
| @@ -276,12 +284,13 @@ export function flyer(l: Launched): string { | ||
| 276 | 284 | <p>${escape(l.agent_phone)} · ${escape(l.agent_email)}</p> |
| 277 | 285 | <p class="flyer-license">License ${escape(l.license)}</p> |
| 278 | 286 | </div> |
| 287 | <p class="flyer-link">See every photo: ${escape(propertyPath(l, origin))}</p> | |
| 279 | 288 | <p class="flyer-fine">Equal Housing Opportunity. Information provided by the listing agent; deemed reliable, not guaranteed.</p> |
| 280 | 289 | </footer> |
| 281 | 290 | </article>`; |
| 282 | 291 | } |
| 283 | 292 | |
| 284 | export function flyerPage(input: { who: Who; listing: Launched }): string { | |
| 293 | export function flyerPage(input: { who: Who; listing: Launched; origin?: string }): string { | |
| 285 | 294 | const { who, listing: l } = input; |
| 286 | 295 | return layout( |
| 287 | 296 | `<div class="print-bar row"> |
| @@ -289,7 +298,7 @@ export function flyerPage(input: { who: Who; listing: Launched }): string { | ||
| 289 | 298 | <button type="button" class="button" data-print hidden>Print</button> |
| 290 | 299 | <span class="muted">Letter size; set margins to none for edge-to-edge.</span> |
| 291 | 300 | </div> |
| 292 | ${flyer(l)}`, | |
| 301 | ${flyer(l, input.origin)}`, | |
| 293 | 302 | who, |
| 294 | 303 | { path: `/listings/${l.id}/flyer`, heading: `Flyer: ${l.address}`, kind: "print" }, |
| 295 | 304 | ); |
src/pages/property.ts added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | /** | |
| 2 | * The property page for buyers, at `/homes/:id`: the photos, the facts, the | |
| 3 | * agent, and the MLS description only once someone approved it. It carries no | |
| 4 | * control, no approval state and no draft text; the author alone, before | |
| 5 | * approval, sees a notice that it is not public yet. | |
| 6 | */ | |
| 7 | ||
| 8 | import { factItems, money, whenOf } from "../generate.ts"; | |
| 9 | import { escape } from "../http.ts"; | |
| 10 | import { propertyPath, publicDescription, type Launched } from "../listing.ts"; | |
| 11 | import type { Who } from "../who.ts"; | |
| 12 | import { layout } from "./layout.ts"; | |
| 13 | import { photoUrl, statusLine } from "./listing.ts"; | |
| 14 | ||
| 15 | export function propertyPage(input: { who: Who; listing: Launched }): string { | |
| 16 | const { who, listing: l } = input; | |
| 17 | const words = publicDescription(l); | |
| 18 | const [hero, ...more] = l.photos; | |
| 19 | const status = l.status === "active" ? "Active" : statusLine(l); | |
| 20 | return layout( | |
| 21 | `<article class="home palette-${escape(l.palette)}"> | |
| 22 | ${words === null ? `<p class="card home-notice" role="note">Not public yet: approve the MLS description</p>` : ""} | |
| 23 | ${hero === undefined ? "" : `<img class="home-hero" src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="1200" height="800">`} | |
| 24 | <header class="home-head"> | |
| 25 | <h2>${escape(l.address)}</h2> | |
| 26 | <p class="home-price brand">${money(l.price)}</p> | |
| 27 | <p><span class="pill status-pill status-${l.status}">${escape(status)}</span></p> | |
| 28 | </header> | |
| 29 | <ul class="home-facts brand-fill"> | |
| 30 | ${factItems(l).map((i) => `<li><b>${escape(i.value)}</b> ${i.label}</li>`).join("\n ")} | |
| 31 | </ul> | |
| 32 | ${l.open_houses.length === 0 || l.status !== "active" ? "" : `<p class="home-open">Open house: ${l.open_houses.map((o) => escape(whenOf(o))).join(" and ")}</p>`} | |
| 33 | ${words === null ? "" : `<p class="home-words">${escape(words).replace(/\n/g, "<br>")}</p>`} | |
| 34 | ${more.length === 0 ? "" : `<ul class="strip">${more.map((p, i) => `<li><img src="${escape(photoUrl(l, p))}" alt="Photo ${i + 2} of ${escape(l.address)}" width="240" height="160" loading="lazy"></li>`).join("")}</ul>`} | |
| 35 | <footer class="home-agent card"> | |
| 36 | ${l.headshot === null ? "" : `<img class="home-face" src="${escape(photoUrl(l, l.headshot))}" alt="Photo of ${escape(l.agent_name)}" width="72" height="72">`} | |
| 37 | <div> | |
| 38 | <p class="home-name brand">${escape(l.agent_name)}</p> | |
| 39 | <p>${escape(l.brokerage)} · License ${escape(l.license)}</p> | |
| 40 | <p><a href="tel:${escape(l.agent_phone.replace(/[^0-9+]/g, ""))}">${escape(l.agent_phone)}</a></p> | |
| 41 | <p><a href="mailto:${escape(l.agent_email)}">${escape(l.agent_email)}</a></p> | |
| 42 | </div> | |
| 43 | </footer> | |
| 44 | <p class="muted"><small>Equal Housing Opportunity.</small></p> | |
| 45 | </article>`, | |
| 46 | who, | |
| 47 | { path: propertyPath(l), heading: l.address }, | |
| 48 | ); | |
| 49 | } |
src/property.test.ts added +126
| @@ -0,0 +1,126 @@ | ||
| 1 | /** The property page: approved words only, hidden until then, linked from the flyer and the email. */ | |
| 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 { publicOrigin } from "./env.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(), "property-")), 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 form = new FormData(); | |
| 23 | for (const [k, v] of Object.entries({ | |
| 24 | address: "7 Property Pl, Portland, OR 97201", beds: 3, baths: 2, sqft: 1800, year: 1999, price: 500000, | |
| 25 | tone: "classic", palette: "teal", agent_name: "Pat Agent", brokerage: "Harbor Realty", license: "OR-1", | |
| 26 | agent_phone: "+1 (503) 555-0100", agent_email: "[email protected]", lot: "0.2 acre", features: "Sunny kitchen\nQuiet street\nNew roof\nBig deck\nOak floors", | |
| 27 | })) | |
| 28 | form.set(k, String(v)); | |
| 29 | ||
| 30 | let id = 0; | |
| 31 | test("a launched listing", async () => { | |
| 32 | const r = await call("/listings", "pat", { method: "POST", body: form }); | |
| 33 | expect(r.status).toBe(303); | |
| 34 | id = Number(/\/listings\/(\d+)/.exec(r.headers.get("location")!)![1]); | |
| 35 | }); | |
| 36 | ||
| 37 | const DRAFT = "DRAFT-ONLY-WORDS"; | |
| 38 | test("an edited draft never reaches buyers, and anon gets 404 before approval", async () => { | |
| 39 | const edit = new FormData(); | |
| 40 | edit.set("text", `${DRAFT} for sale.`); | |
| 41 | await call(`/listings/${id}/assets/mls`, "pat", { method: "POST", body: edit }); | |
| 42 | const anon = await call(`/homes/${id}`); | |
| 43 | expect(anon.status).toBe(404); | |
| 44 | expect(await anon.text()).not.toContain(DRAFT); | |
| 45 | expect((await call(`/homes/${id}`, "other")).status).toBe(404); | |
| 46 | }); | |
| 47 | ||
| 48 | test("the author sees the notice, no draft text and no controls", async () => { | |
| 49 | const r = await call(`/homes/${id}`, "pat"); | |
| 50 | expect(r.status).toBe(200); | |
| 51 | const html = await r.text(); | |
| 52 | expect(html).toContain("Not public yet: approve the MLS description"); | |
| 53 | expect(html).not.toContain(DRAFT); | |
| 54 | expect(html).toContain("7 Property Pl"); | |
| 55 | }); | |
| 56 | ||
| 57 | test("once approved, anyone reads the words, the facts and the agent; nothing to edit", async () => { | |
| 58 | const page = await (await call(`/listings/${id}`, "pat")).text(); | |
| 59 | const seen = /name="seen" value="([^"]+)"/.exec(page)![1]!; | |
| 60 | // The first approve control on the page is the MLS asset's. | |
| 61 | const approve = new URLSearchParams({ approved: "1", seen }); | |
| 62 | const done = await call(`/listings/${id}/assets/mls/approve`, "pat", { | |
| 63 | method: "POST", | |
| 64 | body: approve, | |
| 65 | headers: { ...as("pat"), "content-type": "application/x-www-form-urlencoded" }, | |
| 66 | }); | |
| 67 | expect(done.status).toBe(303); | |
| 68 | const r = await call(`/homes/${id}`); | |
| 69 | expect(r.status).toBe(200); | |
| 70 | const html = await r.text(); | |
| 71 | expect(html).toContain(DRAFT); | |
| 72 | for (const s of ["$500,000", "Active", "Pat Agent", "+1 (503) 555-0100", "[email protected]", "palette-teal"]) | |
| 73 | expect(html).toContain(s); | |
| 74 | expect(html).not.toContain("Not public yet"); | |
| 75 | expect(html).not.toMatch(/<textarea|<form|<button|fh-pill|Draft —/); | |
| 76 | }); | |
| 77 | ||
| 78 | test("withdrawing the approval hides the page again", async () => { | |
| 79 | const done = await call(`/listings/${id}/assets/mls/approve`, "pat", { | |
| 80 | method: "POST", | |
| 81 | body: new URLSearchParams({ approved: "0" }), | |
| 82 | headers: { ...as("pat"), "content-type": "application/x-www-form-urlencoded" }, | |
| 83 | }); | |
| 84 | expect(done.status).toBe(303); | |
| 85 | expect((await call(`/homes/${id}`)).status).toBe(404); | |
| 86 | // restore for the next tests | |
| 87 | const page = await (await call(`/listings/${id}`, "pat")).text(); | |
| 88 | const seen = /name="seen" value="([^"]+)"/.exec(page)![1]!; | |
| 89 | await call(`/listings/${id}/assets/mls/approve`, "pat", { | |
| 90 | method: "POST", | |
| 91 | body: new URLSearchParams({ approved: "1", seen }), | |
| 92 | headers: { ...as("pat"), "content-type": "application/x-www-form-urlencoded" }, | |
| 93 | }); | |
| 94 | }); | |
| 95 | ||
| 96 | test("a sold listing keeps its page, showing Sold", async () => { | |
| 97 | await call(`/listings/${id}/status`, "pat", { | |
| 98 | method: "POST", | |
| 99 | body: new URLSearchParams({ status: "sold", sold_price: "510000" }), | |
| 100 | headers: { ...as("pat"), "content-type": "application/x-www-form-urlencoded" }, | |
| 101 | }); | |
| 102 | const html = await (await call(`/homes/${id}`)).text(); | |
| 103 | expect(html).toContain("Sold at $510,000"); | |
| 104 | }); | |
| 105 | ||
| 106 | test("the listing page, the flyer and the email link to it by the configured public origin", async () => { | |
| 107 | const url = `${publicOrigin}/homes/${id}`; | |
| 108 | const listing = await (await call(`/listings/${id}`, "pat")).text(); | |
| 109 | expect(listing).toContain(`data-copy-link="${url}"`); | |
| 110 | expect(listing).toContain(url); | |
| 111 | expect(await (await call(`/listings/${id}/flyer`)).text()).toContain(url); | |
| 112 | expect(await (await call(`/listings/${id}/email`)).text()).toContain(`href="${url}"`); | |
| 113 | }); | |
| 114 | ||
| 115 | test("a spoofed Host never reaches the flyer or the email link", async () => { | |
| 116 | const host = { host: "evil.example" }; | |
| 117 | for (const path of [`/listings/${id}`, `/listings/${id}/flyer`, `/listings/${id}/email`]) { | |
| 118 | const html = await (await fetch(base + path, { headers: { ...as("pat"), ...host } })).text(); | |
| 119 | expect(html).toContain(`${publicOrigin}/homes/${id}`); | |
| 120 | expect(html).not.toContain("evil.example"); | |
| 121 | } | |
| 122 | }); | |
| 123 | ||
| 124 | test("an unknown home is 404", async () => { | |
| 125 | expect((await call("/homes/99999")).status).toBe(404); | |
| 126 | }); |
src/routes.ts +13 −2
| @@ -23,6 +23,7 @@ import { | ||
| 23 | 23 | deleteNote, |
| 24 | 24 | editAsset, |
| 25 | 25 | getAudit, |
| 26 | getHome, | |
| 26 | 27 | getListing, |
| 27 | 28 | getNote, |
| 28 | 29 | launchListing, |
| @@ -64,6 +65,8 @@ import { draftOf, home, type Draft } from "./pages/home.ts"; | ||
| 64 | 65 | import { layout } from "./pages/layout.ts"; |
| 65 | 66 | import { factsPage } from "./pages/facts.ts"; |
| 66 | 67 | import { emailPage } from "./pages/email.ts"; |
| 68 | import { publicOrigin } from "./env.ts"; | |
| 69 | import { propertyPage } from "./pages/property.ts"; | |
| 67 | 70 | import { isPost, lengthLine } from "./postlength.ts"; |
| 68 | 71 | import { approval, asideOf, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts"; |
| 69 | 72 | import { notesPage } from "./pages/notes.ts"; |
| @@ -465,6 +468,7 @@ export const routes: readonly Route[] = [ | ||
| 465 | 468 | listingPage({ |
| 466 | 469 | who: c.who, |
| 467 | 470 | listing: found.value, |
| 471 | origin: publicOrigin, | |
| 468 | 472 | editable: canEditListing(c.who, found.value), |
| 469 | 473 | tab: c.url.searchParams.get("tab"), |
| 470 | 474 | notice, |
| @@ -522,12 +526,19 @@ export const routes: readonly Route[] = [ | ||
| 522 | 526 | : back(done.message, to); |
| 523 | 527 | }, |
| 524 | 528 | }, |
| 529 | { | |
| 530 | method: "GET", | |
| 531 | path: "/homes/:id", | |
| 532 | handle: (c) => { | |
outdated
Add the contract.test.ts row for this door. |
||
| 533 | return page(c.who, getHome(c.db, c.who, { id: Number(c.params.id) }), (listing) => propertyPage({ who: c.who, listing })); | |
| 534 | }, | |
| 535 | }, | |
| 525 | 536 | { |
| 526 | 537 | method: "GET", |
| 527 | 538 | path: "/listings/:id/flyer", |
| 528 | 539 | handle: (c) => |
| 529 | 540 | page(c.who, getListing(c.db, c.who, { id: Number(c.params.id) }), (listing) => |
| 530 | flyerPage({ who: c.who, listing }), | |
| 541 | flyerPage({ who: c.who, listing, origin: publicOrigin }), | |
| 531 | 542 | ), |
| 532 | 543 | }, |
| 533 | 544 | { |
| @@ -551,7 +562,7 @@ export const routes: readonly Route[] = [ | ||
| 551 | 562 | path: "/listings/:id/email", |
| 552 | 563 | handle: (c) => |
| 553 | 564 | page(c.who, getListing(c.db, c.who, { id: Number(c.params.id) }), (listing) => |
| 554 | emailPage({ who: c.who, listing }), | |
| 565 | emailPage({ who: c.who, listing, origin: publicOrigin }), | |
| 555 | 566 | ), |
| 556 | 567 | }, |
| 557 | 568 | { |
src/sample.ts +6 −1
| @@ -135,7 +135,12 @@ export function seed(db: Database): void { | ||
| 135 | 135 | for (const s of SAMPLES) { |
| 136 | 136 | const done = db.query("INSERT OR IGNORE INTO seeded (name) VALUES (?1)").run(s.name); |
| 137 | 137 | if (done.changes === 0) continue; |
| 138 | insertListing(db, { id: "sample", name: "Sample listing" }, s.listing, s.photos, s.headshot); | |
| 138 | const made = insertListing(db, { id: "sample", name: "Sample listing" }, s.listing, s.photos, s.headshot); | |
| 139 | // The first sample's MLS text is approved, so the preview's property page shows words, not a 404. | |
| 140 | if (s.name === "sample") | |
| 141 | db.query( | |
| 142 | "UPDATE asset SET approved_by_id = 'sample', approved_by_name = 'Sample listing', approved_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), approved_via = 'page' WHERE listing_id = ?1 AND kind = 'mls'", | |
| 143 | ).run(made.id); | |
| 139 | 144 | } |
| 140 | 145 | // A sample planted before statuses existed is marked sold once too; after that it is the owner's to change. |
| 141 | 146 | for (const s of SAMPLES) { |
src/speed.test.ts +23
| @@ -39,6 +39,29 @@ for (const path of PAGES) { | ||
| 39 | 39 | }); |
| 40 | 40 | } |
| 41 | 41 | |
| 42 | test("a property page answers in under 50 ms at the 95th percentile", async () => { | |
| 43 | // A sample is not approved, so its page is hidden: approve the MLS description as its author would. | |
| 44 | const identity = `speed-agent|Speed Agent|user|${Date.now() + 600_000}`; | |
| 45 | const headers = { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) }; | |
| 46 | const form = new FormData(); | |
| 47 | for (const [k, v] of Object.entries({ ...SAMPLE, features: SAMPLE.features.join("\n") })) form.set(k, String(v)); | |
| 48 | const made = await fetch(base + "/listings", { method: "POST", body: form, headers, redirect: "manual" }); | |
| 49 | const to = made.headers.get("location")!; | |
| 50 | const id = /\/listings\/(\d+)/.exec(to)![1]; | |
| 51 | const seen = /name="seen" value="([^"]+)"/.exec(await (await fetch(base + to, { headers })).text())![1]!; | |
| 52 | await fetch(`${base}/listings/${id}/assets/mls/approve`, { method: "POST", body: new URLSearchParams({ approved: "1", seen }), headers, redirect: "manual" }); | |
| 53 | const path = `/homes/${id}`; | |
| 54 | expect((await fetch(base + path)).status).toBe(200); | |
| 55 | const times: number[] = []; | |
| 56 | for (let i = 0; i < 50; i++) { | |
| 57 | const start = performance.now(); | |
| 58 | await (await fetch(base + path)).text(); | |
| 59 | times.push(performance.now() - start); | |
| 60 | } | |
| 61 | times.sort((a, b) => a - b); | |
| 62 | expect(times[47]).toBeLessThan(50); | |
| 63 | }); | |
| 64 | ||
| 42 | 65 | test("static files are cached for a year, by their address", async () => { |
| 43 | 66 | const page = await (await fetch(base + "/")).text(); |
| 44 | 67 | const hrefs = [...page.matchAll(/(?:href|src)="(\/(?:static|tokens)[^"]+)"/g)].map( |
src/tty.test.ts +13
| @@ -8,6 +8,7 @@ import { mkdtempSync } from "node:fs"; | ||
| 8 | 8 | import { tmpdir } from "node:os"; |
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { editAsset, getListing, insertListing } from "./actions.ts"; |
| 11 | import { publicOrigin } from "./env.ts"; | |
| 11 | 12 | import { openDb } from "./db.ts"; |
| 12 | 13 | import { SAMPLE } from "./sample.ts"; |
| 13 | 14 | import { fromLine, session, toLine } from "./tty.ts"; |
| @@ -223,3 +224,15 @@ test("the terminal shows a social post's length, and a short post over X needs a | ||
| 223 | 224 | const outcomes = db.query<{ outcome: string }, []>("SELECT outcome FROM audit WHERE outcome LIKE 'approved%'").all(); |
| 224 | 225 | expect(outcomes.map((r) => r.outcome)).toEqual(["approved_too_long_for_x"]); |
| 225 | 226 | }); |
| 227 | ||
| 228 | test("the listing view shows the property page URL, and that it is not public until the MLS text is approved", () => { | |
| 229 | const { db, id } = fresh(); | |
| 230 | const tty = session(db, me); | |
| 231 | tty.press("\r"); | |
| 232 | const url = `${publicOrigin}/homes/${id}`; | |
| 233 | expect(plain(tty.screen())).toContain(`Property page: ${url} (not public yet: approve the MLS description)`); | |
| 234 | tty.press("a"); // approve the MLS description under the cursor | |
| 235 | const after = plain(tty.screen()); | |
| 236 | expect(after).toContain(`Property page: ${url}`); | |
| 237 | expect(after).not.toContain("not public yet"); | |
| 238 | }); |
src/tty.ts +3 −3
| @@ -34,9 +34,9 @@ import { | ||
| 34 | 34 | updateFacts, |
| 35 | 35 | useSuggestion, |
| 36 | 36 | } from "./actions.ts"; |
| 37 | import { ssh, title, where } from "./env.ts"; | |
| 37 | import { publicOrigin, ssh, title, where } from "./env.ts"; | |
| 38 | 38 | import { kit } from "./kit.ts"; |
| 39 | import { approvalLabel } from "./listing.ts"; | |
| 39 | import { approvalLabel, propertyLine } from "./listing.ts"; | |
| 40 | 40 | import { isPost, lengthLine, meantForX, overXNote } from "./postlength.ts"; |
| 41 | 41 | import { emailParts } from "./pages/email.ts"; |
| 42 | 42 | import { factItems, money, openHouseLine, TONES, type Kind, type OpenHouse } from "./generate.ts"; |
| @@ -133,7 +133,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 133 | 133 | } |
| 134 | 134 | const signed = l.assets.filter((a) => a.approval !== null).length; |
| 135 | 135 | const whenLine = openHouseLine(l); |
| 136 | const head = [bold(`${l.address} · ${money(l.price)}`), dim(`${l.tone} tone · ${approvalLabel(signed, l.assets.length)}${l.status === "active" ? "" : ` · ${statusToLine(l)}`}`), ...(whenLine === null ? [] : [whenLine]), ""]; | |
| 136 | const head = [bold(`${l.address} · ${money(l.price)}`), dim(`${l.tone} tone · ${approvalLabel(signed, l.assets.length)}${l.status === "active" ? "" : ` · ${statusToLine(l)}`}`), ...(whenLine === null ? [] : [whenLine]), dim(propertyLine(l, publicOrigin)), ""]; | |
| 137 | 137 | // Scroll so the asset under the cursor starts the page. |
| 138 | 138 | const body = l.assets.slice(cursor).flatMap((a, i) => [ |
| 139 | 139 | `${i === 0 ? "▸" : " "} ${i === 0 ? bold(a.label) : a.label} ${a.check.clear ? green("Fair Housing: clear") : amber(`Fair Housing: ${a.check.findings.length} to review`)}${a.edited ? dim(" edited") : ""}${a.approval === null ? "" : green(` approved by ${a.approval.by_name}`)}`, |
src/walkthrough.test.ts +4
| @@ -241,4 +241,8 @@ test("every step of the section works, with the labels it names", async () => { | ||
| 241 | 241 | const flyer = await get(`${mine}/flyer`); |
| 242 | 242 | expect(flyer.status).toBe(200); |
| 243 | 243 | expect(await flyer.text()).toContain("9 Walkthrough Way"); |
| 244 | ||
| 245 | // 10. The property page: linked from the listing, hidden from buyers until the MLS description is approved. | |
| 246 | expect(await (await get(mine)).text()).toContain("data-copy-link="); | |
| 247 | expect((await fetch(`${base}${mine.replace("/listings/", "/homes/")}`)).status).toBe(404); | |
| 244 | 248 | }); |
static/app.css +76
| @@ -972,3 +972,79 @@ main:focus { | ||
| 972 | 972 | .status-form { |
| 973 | 973 | flex-wrap: wrap; |
| 974 | 974 | } |
| 975 | ||
| 976 | /* The property page buyers read, and the links that lead to it. */ | |
| 977 | .property-link { | |
| 978 | flex-wrap: wrap; | |
| 979 | margin-bottom: var(); | |
| 980 | overflow-wrap: anywhere; | |
| 981 | } | |
| 982 | .flyer-link { | |
| 983 | flex-basis: 100%; | |
| 984 | font-size: var(); | |
| 985 | overflow-wrap: anywhere; | |
| 986 | } | |
| 987 | .email-cta { | |
| 988 | margin: var() 0; | |
| 989 | } | |
| 990 | .email-button { | |
| 991 | display: inline-block; | |
| 992 | padding: var() var(); | |
| 993 | border-radius: var(); | |
| 994 | font-weight: 600; | |
| 995 | text-decoration: none; | |
| 996 | } | |
| 997 | .home { | |
| 998 | max-width: 48rem; | |
| 999 | margin: 0 auto; | |
| 1000 | } | |
| 1001 | .home-notice { | |
| 1002 | font-weight: 600; | |
| 1003 | } | |
| 1004 | .home-hero { | |
| 1005 | display: block; | |
| 1006 | width: 100%; | |
| 1007 | height: auto; | |
| 1008 | aspect-ratio: 3 / 2; | |
| 1009 | object-fit: cover; | |
| 1010 | border-radius: var(); | |
| 1011 | margin-bottom: var(); | |
| 1012 | } | |
| 1013 | .home-price { | |
| 1014 | font-family: var(); | |
| 1015 | font-size: var(); | |
| 1016 | font-weight: 600; | |
| 1017 | margin: 0 0 var(); | |
| 1018 | } | |
| 1019 | .home-facts { | |
| 1020 | display: flex; | |
| 1021 | flex-wrap: wrap; | |
| 1022 | gap: var() var(); | |
| 1023 | list-style: none; | |
| 1024 | margin: var() 0; | |
| 1025 | padding: var() var(); | |
| 1026 | border-radius: var(); | |
| 1027 | } | |
| 1028 | .home-open { | |
| 1029 | font-weight: 600; | |
| 1030 | } | |
| 1031 | .home-words { | |
| 1032 | line-height: var(); | |
| 1033 | margin: var() 0; | |
| 1034 | } | |
| 1035 | .home-agent { | |
| 1036 | display: flex; | |
| 1037 | flex-wrap: wrap; | |
| 1038 | gap: var(); | |
| 1039 | align-items: center; | |
| 1040 | } | |
| 1041 | .home-agent p { | |
| 1042 | margin: 0; | |
| 1043 | } | |
| 1044 | .home-name { | |
| 1045 | font-weight: 600; | |
| 1046 | } | |
| 1047 | .home-face { | |
| 1048 | border-radius: 50%; | |
| 1049 | object-fit: cover; | |
| 1050 | } |
static/listing.js +11
| @@ -54,6 +54,17 @@ for (const button of document.querySelectorAll("[data-copy]")) { | ||
| 54 | 54 | }); |
| 55 | 55 | } |
| 56 | 56 | |
| 57 | for (const button of document.querySelectorAll("[data-copy-link]")) { | |
| 58 | if (!navigator.clipboard) continue; | |
| 59 | button.hidden = false; | |
| 60 | button.addEventListener("click", async () => { | |
| 61 | await navigator.clipboard.writeText(button.dataset.copyLink); | |
| 62 | button.textContent = "Copied"; | |
| 63 | announce("Copied the property page link"); | |
| 64 | setTimeout(() => (button.textContent = "Copy"), 1500); | |
| 65 | }); | |
| 66 | } | |
| 67 | ||
| 57 | 68 | for (const area of document.querySelectorAll("textarea[data-check]")) { |
| 58 | 69 | const kind = area.dataset.check; |
| 59 | 70 | const aside = document.querySelector(`[data-fh="${kind}"]`); |
c.url.origin comes from the client-supplied Host. Use a configured public origin for links that leave the app (email, flyer).