Public: anyone can read this; the crew can change it.
Print a 'Just listed' / 'Just sold' neighbor postcard for a listing, four to a page, in the agent's brand #104
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/88-print-a-just-listed-just-sold-neighbor-p → main 1 commit fork
9 files
changed +287
−2
README.md +8
| @@ -313,6 +313,14 @@ list cannot drift from the app. | ||
| 313 | 313 | of the code. Print it, or save it as a PDF. "Rewrite all" writes every |
| 314 | 314 | asset again in another tone (your edits are replaced, so it asks with a |
| 315 | 315 | tooltip first). |
| 316 | **Print the neighbor postcard.** "Postcards" opens a Letter sheet of four | |
| 317 | identical postcards (`/listings/:id/postcard`), dashed cut lines between | |
| 318 | them: hero photo, a band ("Just listed", "Open house" with its date, or | |
| 319 | "Just sold"), address, price, facts, a sentence or two of the approved MLS | |
| 320 | description, the property page link and QR code, and your photo, name, | |
| 321 | brokerage, license and phone. Until the MLS description is approved the | |
| 322 | sheet is marked "Draft — not approved" and prints none of its words. Print | |
| 323 | at 100% with margins set to none, then cut. | |
| 316 | 324 | |
| 317 | 325 | **Showings.** After each showing, write it down on the listing page: the day |
| 318 | 326 | (today unless you change it; never a future one), the buyer's agent if you |
docs/design/areas/postcard.md added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | # Postcard | |
| 2 | ||
| 3 | New component classes, all built from existing tokens, in `static/app.css`: | |
| 4 | ||
| 5 | - `postcard-sheet` — the Letter sheet at `/listings/:id/postcard`: a 2 × 2 grid of fixed-inch cards with dashed cut lines. | |
| 6 | - `postcard`, `postcard-hero`, `postcard-hero-empty`, `postcard-band`, `postcard-address`, `postcard-price`, `postcard-facts`, `postcard-words`, `postcard-agent`, `postcard-face`, `postcard-name` — one 4.25 × 5.5 in card; `postcard-band` also carries `brand-fill`, the agent's colour. |
src/a11y.test.ts +1
| @@ -53,6 +53,7 @@ async function paths(): Promise<string[]> { | ||
| 53 | 53 | `/listings/${id}?tab=social`, |
| 54 | 54 | `/listings/${id}/facts`, |
| 55 | 55 | `/listings/${id}/flyer`, |
| 56 | `/listings/${id}/postcard`, | |
| 56 | 57 | `/listings/${id}/email`, |
| 57 | 58 | `/listings/${id}/schedule`, |
| 58 | 59 | ]), |
src/hostile.test.ts +2 −2
| @@ -93,7 +93,7 @@ const expectInert = (html: string) => { | ||
| 93 | 93 | expect(html).not.toMatch(/<textarea[^>]*>(?:(?!<\/textarea>)[^])*<\/textarea>[^]*?<script>alert/); |
| 94 | 94 | }; |
| 95 | 95 | |
| 96 | for (const path of ["/", "/listings/:id", "/listings/:id/flyer", "/listings/:id/card", "/listings/:id/report", "/listings/:id/schedule", "/week", "/listings/:id/sign-in", "/listings/:id/email", "/homes/:id"]) { | |
| 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/email", "/homes/:id"]) { | |
| 97 | 97 | test(`${path} escapes every typed word`, async () => { |
| 98 | 98 | const r = await call(path.replace(":id", String(id))); |
| 99 | 99 | expect(r.status).toBe(200); |
| @@ -108,7 +108,7 @@ for (const path of ["/", "/listings/:id", "/listings/:id/flyer", "/listings/:id/ | ||
| 108 | 108 | } |
| 109 | 109 | |
| 110 | 110 | test("share and Open Graph tags escape the listing's words", async () => { |
| 111 | for (const path of [`/listings/${id}`, `/listings/${id}/email`, `/listings/${id}/flyer`, `/homes/${id}`]) { | |
| 111 | for (const path of [`/listings/${id}`, `/listings/${id}/email`, `/listings/${id}/flyer`, `/listings/${id}/postcard`, `/homes/${id}`]) { | |
| 112 | 112 | const html = await (await call(path)).text(); |
| 113 | 113 | const metas = [...html.matchAll(/<meta\b[^>]*>/g)].map((m) => m[0]); |
| 114 | 114 | expect(metas.length).toBeGreaterThan(0); |
src/pages/listing.ts +1
| @@ -273,6 +273,7 @@ export function listingPage(input: { | ||
| 273 | 273 | ${editable ? statusForm(l) : ""} |
| 274 | 274 | ${editable ? `<a class="button" href="/listings/${l.id}/facts">Edit facts</a>` : ""} |
| 275 | 275 | <a class="button" href="/listings/${l.id}/flyer">Print flyer</a> |
| 276 | <a class="button button-quiet" href="/listings/${l.id}/postcard">Postcards</a> | |
| 276 | 277 | ${whenLine === null ? "" : `<a class="button button-quiet" href="/listings/${l.id}/sign-in">Sign-in sheet</a>`} |
| 277 | 278 | <a class="button button-quiet" href="/listings/${l.id}/report">Seller report</a> |
| 278 | 279 | <a class="button button-quiet" href="/listings/${l.id}/schedule">Launch week</a> |
src/pages/postcard.ts added +76
| @@ -0,0 +1,76 @@ | ||
| 1 | /** | |
| 2 | * The neighbor postcard at `/listings/:id/postcard`: four identical | |
| 3 | * postcards on one US Letter sheet, cut marks between them. Its words are | |
| 4 | * the approved MLS description's first sentences; until that is approved the | |
| 5 | * sheet wears the Draft band and prints none of them. | |
| 6 | */ | |
| 7 | ||
| 8 | import { factItems, isOffMarket, money, openHouseLine, STATUS_LABELS, whenOf } from "../generate.ts"; | |
| 9 | import { escape } from "../http.ts"; | |
| 10 | import { altOf, publicDescription, type Launched } from "../listing.ts"; | |
| 11 | import type { Who } from "../who.ts"; | |
| 12 | import { layout } from "./layout.ts"; | |
| 13 | import { draftBand, photoUrl } from "./listing.ts"; | |
| 14 | import { scanBlock } from "./scan.ts"; | |
| 15 | ||
| 16 | export const POSTCARDS_PER_SHEET = 4; | |
| 17 | ||
| 18 | /** What the band says: Just sold, Open house with its date, Just listed, or the plain status. */ | |
| 19 | export function postcardBand(l: Launched): string { | |
| 20 | if (l.status === "sold") return "Just sold"; | |
| 21 | if (l.status !== "active") return STATUS_LABELS[l.status]; | |
| 22 | const first = isOffMarket(l.status) ? undefined : l.open_houses[0]; | |
| 23 | return first === undefined ? "Just listed" : `Open house: ${whenOf(first)}`; | |
| 24 | } | |
| 25 | ||
| 26 | /** The first one or two sentences of the approved description, or null while it is not approved. */ | |
| 27 | export function postcardWords(l: Launched): string | null { | |
| 28 | const text = publicDescription(l); | |
| 29 | if (text === null) return null; | |
| 30 | const open = openHouseLine(l); | |
| 31 | const body = open === null ? text : text.replace(open, ""); | |
| 32 | const sentences = body.replace(/\s+/g, " ").trim().match(/[^.!?]+[.!?]+/g) ?? [body.trim()]; | |
| 33 | return sentences.slice(0, 2).join("").trim(); | |
| 34 | } | |
| 35 | ||
| 36 | function postcard(l: Launched, origin: string): string { | |
| 37 | const [hero] = l.photos; | |
| 38 | const words = postcardWords(l); | |
| 39 | return `<article class="postcard palette-${escape(l.palette)}"> | |
| 40 | ${draftBand(l, "mls")} | |
| 41 | <div class="postcard-hero"> | |
| 42 | ${hero === undefined ? `<div class="postcard-hero-empty brand-fill"></div>` : `<img src="${escape(photoUrl(l, hero))}" alt="${escape(altOf(l, hero, l.address))}" width="1200" height="800">`} | |
| 43 | <p class="postcard-band brand-fill">${escape(postcardBand(l))}</p> | |
| 44 | </div> | |
| 45 | <h2 class="postcard-address">${escape(l.address)}</h2> | |
| 46 | <p class="postcard-price brand">${money(l.price)}</p> | |
| 47 | <ul class="postcard-facts">${factItems(l).map((i) => `<li><b>${escape(i.value)}</b> ${i.label}</li>`).join("")}</ul> | |
| 48 | ${words === null ? "" : `<p class="postcard-words">${escape(words)}</p>`} | |
| 49 | ${scanBlock(l, origin)} | |
| 50 | <footer class="postcard-agent"> | |
| 51 | ${l.headshot === null ? "" : `<img class="postcard-face" src="${escape(photoUrl(l, l.headshot))}" alt="Photo of ${escape(l.agent_name)}" width="96" height="96">`} | |
| 52 | <div> | |
| 53 | <p class="postcard-name brand">${escape(l.agent_name)}</p> | |
| 54 | <p>${escape(l.brokerage)} · License ${escape(l.license)}</p> | |
| 55 | <p>${escape(l.agent_phone)}</p> | |
| 56 | </div> | |
| 57 | </footer> | |
| 58 | </article>`; | |
| 59 | } | |
| 60 | ||
| 61 | export function postcardPage(input: { who: Who; listing: Launched; origin?: string }): string { | |
| 62 | const { who, listing: l } = input; | |
| 63 | const card = postcard(l, input.origin ?? ""); | |
| 64 | return layout( | |
| 65 | `<div class="print-bar row"> | |
| 66 | <a href="/listings/${l.id}">← Back to the listing</a> | |
| 67 | <button type="button" class="button" data-print hidden>Print</button> | |
| 68 | <span class="muted">Letter size, four to a page; cut along the dashed lines. Set margins to none.</span> | |
| 69 | </div> | |
| 70 | <div class="postcard-sheet"> | |
| 71 | ${Array.from({ length: POSTCARDS_PER_SHEET }, () => card).join("\n ")} | |
| 72 | </div>`, | |
| 73 | who, | |
| 74 | { path: `/listings/${l.id}/postcard`, heading: `Postcard: ${l.address}`, kind: "print" }, | |
| 75 | ); | |
| 76 | } |
src/postcard.test.ts added +81
| @@ -0,0 +1,81 @@ | ||
| 1 | /** The neighbor postcard: four to a sheet, the band, the draft mark, escaped words. */ | |
| 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 { serve } from "./server.ts"; | |
| 7 | import { sign } from "./who.ts"; | |
| 8 | ||
| 9 | const KEY = "a-key-only-the-forge-holds"; | |
| 10 | const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "postcard-")), identityKey: KEY }); | |
| 11 | const base = `http://${server.hostname}:${server.port}`; | |
| 12 | afterAll(() => server.stop(true)); | |
| 13 | ||
| 14 | const identity = `${"pat"}|pat|user|${Date.now() + 600_000}`; | |
| 15 | const headers = { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) }; | |
| 16 | const call = (path: string, init: RequestInit = {}) => fetch(base + path, { redirect: "manual", ...init, headers }); | |
| 17 | ||
| 18 | const launch = async (extra: Record<string, string>): Promise<string> => { | |
| 19 | const form = new FormData(); | |
| 20 | for (const [k, v] of Object.entries({ | |
| 21 | address: "9 Postcard Ln, Portland, OR 97201", beds: 3, baths: 2, sqft: 1800, year: 1999, price: 500000, | |
| 22 | tone: "classic", palette: "teal", agent_name: "Pat Agent", brokerage: "Harbor Realty", license: "OR-1", | |
| 23 | agent_phone: "+1 (503) 555-0100", agent_email: "[email protected]", lot: "0.2 acre", | |
| 24 | features: "Sunny kitchen\nQuiet street\nNew roof\nBig deck\nOak floors", ...extra, | |
| 25 | })) | |
| 26 | form.set(k, String(v)); | |
| 27 | form.set("again", "1"); | |
| 28 | const made = await call("/listings", { method: "POST", body: form }); | |
| 29 | return /\/listings\/(\d+)/.exec(made.headers.get("location")!)![1]!; | |
| 30 | }; | |
| 31 | const approveMls = async (id: string) => { | |
| 32 | const page = await (await call(`/listings/${id}`)).text(); | |
| 33 | const seen = /name="seen" value="([^"]+)"/.exec(page)![1]!; | |
| 34 | return call(`/listings/${id}/assets/mls/approve`, { | |
| 35 | method: "POST", | |
| 36 | body: new URLSearchParams({ approved: "1", seen }), | |
| 37 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 38 | }); | |
| 39 | }; | |
| 40 | const html = async (id: string) => (await call(`/listings/${id}/postcard`)).text(); | |
| 41 | const count = (h: string, s: string) => h.split(s).length - 1; | |
| 42 | ||
| 43 | test("four postcards, Draft and no words until the description is approved", async () => { | |
| 44 | const id = await launch({}); | |
| 45 | const h = await html(id); | |
| 46 | expect(count(h, `<article class="postcard `)).toBe(4); | |
| 47 | expect(count(h, "Draft — not approved")).toBe(4); | |
| 48 | expect(h).toContain("Just listed"); | |
| 49 | expect(h).not.toContain("postcard-words"); | |
| 50 | expect(h).toContain("Harbor Realty"); | |
| 51 | expect(h).toContain("OR-1"); | |
| 52 | }); | |
| 53 | ||
| 54 | test("approved: the first sentences print, no Draft mark, and the listing links here", async () => { | |
| 55 | const id = await launch({}); | |
| 56 | await approveMls(id); | |
| 57 | const h = await html(id); | |
| 58 | expect(count(h, "Draft — not approved")).toBe(0); | |
| 59 | expect(h).toContain("postcard-words"); | |
| 60 | expect(h).toContain("Offered at $500,000"); | |
| 61 | expect(await (await call(`/listings/${id}`)).text()).toContain(`href="/listings/${id}/postcard"`); | |
| 62 | }); | |
| 63 | ||
| 64 | test("the band says Open house with the date when one is scheduled", async () => { | |
| 65 | const id = await launch({ open1_date: "2027-03-06", open1_start: "11:00", open1_end: "13:30" }); | |
| 66 | const h = await html(id); | |
| 67 | expect(h).toContain("Open house: Saturday, March 6"); | |
| 68 | expect(h).not.toContain(">Just listed<"); | |
| 69 | }); | |
| 70 | ||
| 71 | test("the band says Just sold for a sold listing", async () => { | |
| 72 | const id = await launch({}); | |
| 73 | const f = new FormData(); | |
| 74 | f.set("status", "sold"); | |
| 75 | await call(`/listings/${id}/status`, { method: "POST", body: f, headers: { ...headers, origin: base } }); | |
| 76 | expect(await html(id)).toContain(">Just sold<"); | |
| 77 | }); | |
| 78 | ||
| 79 | test("an unknown listing is not found", async () => { | |
| 80 | expect((await call("/listings/99999/postcard")).status).toBe(404); | |
| 81 | }); |
src/routes.ts +9
| @@ -87,6 +87,7 @@ import { scheduleIcs, weekIcs } from "./schedule.ts"; | ||
| 87 | 87 | import { weekPage } from "./pages/week.ts"; |
| 88 | 88 | import { now } from "./clock.ts"; |
| 89 | 89 | import { dayOf } from "./showings.ts"; |
| 90 | import { postcardPage } from "./pages/postcard.ts"; | |
| 90 | 91 | import { signInPage } from "./pages/signin.ts"; |
| 91 | 92 | import { cardPage } from "./pages/card.ts"; |
| 92 | 93 | import { approval, asideOf, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts"; |
| @@ -630,6 +631,14 @@ export const routes: readonly Route[] = [ | ||
| 630 | 631 | flyerPage({ who: c.who, listing, origin: publicOrigin }), |
| 631 | 632 | ), |
| 632 | 633 | }, |
| 634 | { | |
| 635 | method: "GET", | |
| 636 | path: "/listings/:id/postcard", | |
| 637 | handle: (c) => | |
| 638 | page(c.who, getListing(c.db, c.who, { id: Number(c.params.id) }), (listing) => | |
| 639 | postcardPage({ who: c.who, listing, origin: publicOrigin }), | |
| 640 | ), | |
| 641 | }, | |
| 633 | 642 | { |
| 634 | 643 | method: "GET", |
| 635 | 644 | path: "/listings/:id/card", |
static/app.css +103
| @@ -1325,3 +1325,106 @@ main:focus { | ||
| 1325 | 1325 | padding: var() var() var() 0; |
| 1326 | 1326 | border-bottom: 1px solid var(); |
| 1327 | 1327 | } |
| 1328 | ||
| 1329 | /* The neighbor postcard: four 4.25 x 5.5 in cards on a Letter sheet, cut lines between. */ | |
| 1330 | .postcard-sheet { | |
| 1331 | display: grid; | |
| 1332 | grid-template-columns: repeat(2, 4.25in); | |
| 1333 | grid-auto-rows: 5.5in; | |
| 1334 | width: 8.5in; | |
| 1335 | margin: 0 auto; | |
| 1336 | background: var(); | |
| 1337 | color: var(); | |
| 1338 | } | |
| 1339 | .postcard { | |
| 1340 | box-sizing: border-box; | |
| 1341 | width: 4.25in; | |
| 1342 | height: 5.5in; | |
| 1343 | padding: 0.2in; | |
| 1344 | overflow: hidden; | |
| 1345 | border: 1px dashed var(); | |
| 1346 | font-size: 9pt; | |
| 1347 | line-height: 1.25; | |
| 1348 | display: flex; | |
| 1349 | flex-direction: column; | |
| 1350 | gap: 0.05in; | |
| 1351 | } | |
| 1352 | .postcard p, | |
| 1353 | .postcard h2, | |
| 1354 | .postcard ul { | |
| 1355 | margin: 0; | |
| 1356 | padding: 0; | |
| 1357 | } | |
| 1358 | .postcard-hero { | |
| 1359 | position: relative; | |
| 1360 | height: 1.9in; | |
| 1361 | flex: none; | |
| 1362 | } | |
| 1363 | .postcard-hero img, | |
| 1364 | .postcard-hero-empty { | |
| 1365 | display: block; | |
| 1366 | width: 100%; | |
| 1367 | height: 100%; | |
| 1368 | object-fit: cover; | |
| 1369 | } | |
| 1370 | .postcard-band { | |
| 1371 | position: absolute; | |
| 1372 | left: 0; | |
| 1373 | bottom: 0; | |
| 1374 | padding: 0.04in 0.12in; | |
| 1375 | font-weight: 700; | |
| 1376 | font-size: 11pt; | |
| 1377 | -webkit-print-color-adjust: exact; | |
| 1378 | print-color-adjust: exact; | |
| 1379 | } | |
| 1380 | .postcard-address { | |
| 1381 | font-size: 12pt; | |
| 1382 | } | |
| 1383 | .postcard-price { | |
| 1384 | font-size: 15pt; | |
| 1385 | font-weight: 700; | |
| 1386 | } | |
| 1387 | .postcard-facts { | |
| 1388 | display: flex; | |
| 1389 | flex-wrap: wrap; | |
| 1390 | gap: 0 0.12in; | |
| 1391 | list-style: none; | |
| 1392 | } | |
| 1393 | .postcard-words { | |
| 1394 | overflow: hidden; | |
| 1395 | max-height: 0.9in; | |
| 1396 | } | |
| 1397 | .postcard .scan { | |
| 1398 | display: flex; | |
| 1399 | gap: 0.1in; | |
| 1400 | align-items: center; | |
| 1401 | font-size: 8pt; | |
| 1402 | } | |
| 1403 | .postcard .scan .qr { | |
| 1404 | width: 0.8in; | |
| 1405 | height: 0.8in; | |
| 1406 | flex: none; | |
| 1407 | } | |
| 1408 | .postcard .draft-band { | |
| 1409 | margin: 0; | |
| 1410 | } | |
| 1411 | .postcard-agent { | |
| 1412 | display: flex; | |
| 1413 | gap: 0.1in; | |
| 1414 | align-items: center; | |
| 1415 | margin-top: auto; | |
| 1416 | } | |
| 1417 | .postcard-face { | |
| 1418 | width: 0.55in; | |
| 1419 | height: 0.55in; | |
| 1420 | border-radius: 50%; | |
| 1421 | object-fit: cover; | |
| 1422 | } | |
| 1423 | .postcard-name { | |
| 1424 | font-weight: 700; | |
| 1425 | } | |
| 1426 | @media print { | |
| 1427 | .postcard-sheet { | |
| 1428 | margin: 0; | |
| 1429 | } | |
| 1430 | } |