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

Merged opened this

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

Merged

mason/listing-launch:mason/88-print-a-just-listed-just-sold-neighbor-p → main 1 commit fork

  • Merged. mason/88-print-a-just-listed-just-sold-neighbor-p was merged into main as 2abe812.

Review

Sign in to review this change.
9 files changed +287 −2
README.md +8
@@ -313,6 +313,14 @@ list cannot drift from the app.
313313 of the code. Print it, or save it as a PDF. "Rewrite all" writes every
314314 asset again in another tone (your edits are replaced, so it asks with a
315315 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.
316324
317325 **Showings.** After each showing, write it down on the listing page: the day
318326 (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[]> {
5353 `/listings/${id}?tab=social`,
5454 `/listings/${id}/facts`,
5555 `/listings/${id}/flyer`,
56 `/listings/${id}/postcard`,
5657 `/listings/${id}/email`,
5758 `/listings/${id}/schedule`,
5859 ]),
src/hostile.test.ts +2 −2
@@ -93,7 +93,7 @@ const expectInert = (html: string) => {
9393 expect(html).not.toMatch(/<textarea[^>]*>(?:(?!<\/textarea>)[^])*<\/textarea>[^]*?<script>alert/);
9494 };
9595
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"]) {
9797 test(`${path} escapes every typed word`, async () => {
9898 const r = await call(path.replace(":id", String(id)));
9999 expect(r.status).toBe(200);
@@ -108,7 +108,7 @@ for (const path of ["/", "/listings/:id", "/listings/:id/flyer", "/listings/:id/
108108 }
109109
110110 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}`]) {
112112 const html = await (await call(path)).text();
113113 const metas = [...html.matchAll(/<meta\b[^>]*>/g)].map((m) => m[0]);
114114 expect(metas.length).toBeGreaterThan(0);
src/pages/listing.ts +1
@@ -273,6 +273,7 @@ export function listingPage(input: {
273273 ${editable ? statusForm(l) : ""}
274274 ${editable ? `<a class="button" href="/listings/${l.id}/facts">Edit facts</a>` : ""}
275275 <a class="button" href="/listings/${l.id}/flyer">Print flyer</a>
276 <a class="button button-quiet" href="/listings/${l.id}/postcard">Postcards</a>
276277 ${whenLine === null ? "" : `<a class="button button-quiet" href="/listings/${l.id}/sign-in">Sign-in sheet</a>`}
277278 <a class="button button-quiet" href="/listings/${l.id}/report">Seller report</a>
278279 <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";
8787 import { weekPage } from "./pages/week.ts";
8888 import { now } from "./clock.ts";
8989 import { dayOf } from "./showings.ts";
90 import { postcardPage } from "./pages/postcard.ts";
9091 import { signInPage } from "./pages/signin.ts";
9192 import { cardPage } from "./pages/card.ts";
9293 import { approval, asideOf, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts";
@@ -630,6 +631,14 @@ export const routes: readonly Route[] = [
630631 flyerPage({ who: c.who, listing, origin: publicOrigin }),
631632 ),
632633 },
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 },
633642 {
634643 method: "GET",
635644 path: "/listings/:id/card",
static/app.css +103
@@ -1325,3 +1325,106 @@ main:focus {
13251325 padding: var(--space-1) var(--space-3) var(--space-1) 0;
13261326 border-bottom: 1px solid var(--color-line);
13271327 }
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(--color-paper);
1337 color: var(--color-ink);
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(--color-muted);
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 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs