Public: anyone can read this; the crew can change it.
Print an 'Open house' directional sign for a listing: big OPEN HOUSE, the date and time, an arrow, the agent's name and phone, in the agent's brand #136
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/135-print-an-open-house-directional-sign-for → main 2 commits fork
7 files
changed +275
−0
README.md +7
| @@ -63,6 +63,13 @@ to make a listing of your own. | ||
| 63 | 63 | times, your name and phone in your colour, and fifteen blank rows for name, |
| 64 | 64 | email, phone and whether the visitor works with an agent. Print it; nothing |
| 65 | 65 | typed on paper is stored. A listing with no open house answers 404 there. |
| 66 | Beside it, **Print the open house sign** (`/listings/<id>/sign`, on *412 | |
| 67 | Alder Lane* too): one Letter landscape page for the corner post, with OPEN | |
| 68 | HOUSE in your colour, the next open house's day and time, the address, a | |
| 69 | large arrow, your name and phone and the property page's QR code. Choose | |
| 70 | the arrow with the links above the page (`?arrow=left`, `right` or `up`). | |
| 71 | A listing with no upcoming open house says to add one first; a sold or | |
| 72 | off-market one says so. | |
| 66 | 73 | Beside it, **Sign-in screen for a tablet** (`/listings/<id>/open-house`, on |
| 67 | 74 | *412 Alder Lane* too) is for handing the device to visitors at the door, |
| 68 | 75 | on your own signed-in session: your name and colour, the address and |
docs/design/areas/open-house.md +1
| @@ -7,3 +7,4 @@ New component classes, all built from existing tokens: | ||
| 7 | 7 | - `open-grid` — the form's date / start / end row, a `facts-grid` with the same columns. |
| 8 | 8 | - `signin`, `signin-head`, `signin-hero`, `signin-address`, `signin-price`, `signin-open`, `signin-agent`, `signin-table` — the sign-in sheet at `/listings/:id/sign-in` (`static/app.css`): a hero and the facts above a table of fifteen blank rows; `signin-open` also carries `brand-fill`. |
| 9 | 9 | - `kiosk`, `kiosk-ask`, `kiosk-form`, `kiosk-input`, `kiosk-go`, `kiosk-error` — the sign-in screen for a tablet at `/listings/:id/open-house` (`static/app.css`): large inputs and one full-width "Sign in" button in the agent's colour (`brand-fill`); it reuses `home-hero`, `field`, `input` and `toast`. |
| 10 | - `sign`, `sign-title`, `sign-main`, `sign-words`, `sign-when`, `sign-address`, `sign-arrow`, `sign-arrow-svg`, `sign-foot`, `sign-agent`, `sign-qr`, `sign-notice` — the directional sign at `/listings/:id/sign` (`static/app.css`): a Letter landscape page (its own `@page sign`) with OPEN HOUSE in a `brand-fill` band, the day and time, the address, a large arrow, the agent and the property page QR; `sign-notice` is the plain page when there is no open house or the listing is off the market. |
src/pages/listing.ts +1
| @@ -330,6 +330,7 @@ export function listingPage(input: { | ||
| 330 | 330 | <a class="button" href="/listings/${l.id}/flyer">Print flyer</a> |
| 331 | 331 | <a class="button button-quiet" href="/listings/${l.id}/postcard">Postcards</a> |
| 332 | 332 | ${whenLine === null ? "" : `<a class="button button-quiet" href="/listings/${l.id}/sign-in">Sign-in sheet</a>`} |
| 333 | ${whenLine === null ? "" : `<a class="button button-quiet" href="/listings/${l.id}/sign">Print the open house sign</a>`} | |
| 333 | 334 | ${!editable ? "" : `<a class="button button-quiet" href="/listings/${l.id}/open-house">Sign-in screen for a tablet</a>`} |
| 334 | 335 | <a class="button button-quiet" href="/listings/${l.id}/report">Seller report</a> |
| 335 | 336 | <a class="button button-quiet" href="/listings/${l.id}/schedule">Launch week</a> |
src/pages/sign.ts added +78
| @@ -0,0 +1,78 @@ | ||
| 1 | /** | |
| 2 | * The open house directional sign at `/listings/:id/sign`: one Letter | |
| 3 | * landscape page to tape to the corner post, in the agent's colour. Server | |
| 4 | * rendered, no script, no remote asset. | |
| 5 | */ | |
| 6 | ||
| 7 | import { isOffMarket, openHouseLine, whenOf } from "../generate.ts"; | |
| 8 | import { escape } from "../http.ts"; | |
| 9 | import type { Launched } from "../listing.ts"; | |
| 10 | import { qrSvg } from "../qr.ts"; | |
| 11 | import { propertyPath, publicDescription } from "../listing.ts"; | |
| 12 | import type { Who } from "../who.ts"; | |
| 13 | import { layout } from "./layout.ts"; | |
| 14 | ||
| 15 | export const ARROWS = ["left", "right", "up"] as const; | |
| 16 | export type Arrow = (typeof ARROWS)[number]; | |
| 17 | ||
| 18 | /** `right` unless the query names one of the three. */ | |
| 19 | export const arrowOf = (raw: string | null): Arrow => (ARROWS as readonly string[]).includes(raw ?? "") ? (raw as Arrow) : "right"; | |
| 20 | ||
| 21 | // One arrow pointing right, turned for the other two. | |
| 22 | const TURN: Record<Arrow, number> = { right: 0, left: 180, up: 270 }; | |
| 23 | const arrowSvg = (a: Arrow): string => | |
| 24 | `<svg class="sign-arrow-svg" viewBox="0 0 100 100" role="img" aria-label="Arrow pointing ${a}"><g transform="rotate(${TURN[a]} 50 50)"><path d="M6 38h54V12l34 38-34 38V62H6z" fill="currentColor"/></g></svg>`; | |
| 25 | ||
| 26 | /** The first open house that is not before `today` (YYYY-MM-DD), as the flyer and email word it. */ | |
| 27 | export const nextOpenHouse = (l: Launched, today: string): string | null => { | |
| 28 | const next = [...l.open_houses].filter((o) => o.date >= today).sort((a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start))[0]; | |
| 29 | return next === undefined ? null : whenOf(next); | |
| 30 | }; | |
| 31 | ||
| 32 | const notice = (who: Who, l: Launched, text: string): string => | |
| 33 | layout( | |
| 34 | `<article class="sign-notice"> | |
| 35 | <p><a href="/listings/${l.id}">← Back to the listing</a></p> | |
| 36 | <h2>${escape(l.address)}</h2> | |
| 37 | <p role="note">${text}</p> | |
| 38 | </article>`, | |
| 39 | who, | |
| 40 | { path: `/listings/${l.id}/sign`, heading: `Open house sign: ${l.address}`, kind: "print" }, | |
| 41 | ); | |
| 42 | ||
| 43 | export function signPage(input: { who: Who; listing: Launched; today: string; arrow: Arrow; origin?: string }): string { | |
| 44 | const { who, listing: l, arrow } = input; | |
| 45 | if (isOffMarket(l.status) || l.status === "sold") | |
| 46 | return notice(who, l, `This listing is off the market, so there is no open house to point to.`); | |
| 47 | const when = nextOpenHouse(l, input.today); | |
| 48 | if (when === null || openHouseLine(l) === null) | |
| 49 | return notice(who, l, `Add an open house to <a href="/listings/${l.id}">the listing</a> first, then print its sign.`); | |
| 50 | const url = propertyPath(l, input.origin ?? ""); | |
| 51 | const code = publicDescription(l) === null ? "" : qrSvg(url, "QR code for the property page"); | |
| 52 | const choices = ARROWS.map((a) => | |
| 53 | a === arrow ? `<b aria-current="true">${a}</b>` : `<a href="/listings/${l.id}/sign?arrow=${a}">${a}</a>`, | |
| 54 | ).join(" · "); | |
| 55 | return layout( | |
| 56 | `<div class="print-bar row"> | |
| 57 | <a href="/listings/${l.id}">← Back to the listing</a> | |
| 58 | <span>Arrow: ${choices}</span> | |
| 59 | <span class="muted">Letter landscape, one page.</span> | |
| 60 | </div> | |
| 61 | <article class="sign palette-${escape(l.palette)}"> | |
| 62 | <h2 class="sign-title brand-fill">OPEN HOUSE</h2> | |
| 63 | <div class="sign-main"> | |
| 64 | <div class="sign-words"> | |
| 65 | <p class="sign-when brand">${escape(when)}</p> | |
| 66 | <p class="sign-address">${escape(l.address)}</p> | |
| 67 | </div> | |
| 68 | <div class="sign-arrow brand">${arrowSvg(arrow)}</div> | |
| 69 | </div> | |
| 70 | <footer class="sign-foot"> | |
| 71 | <p class="sign-agent"><b class="brand">${escape(l.agent_name)}</b> · ${escape(l.agent_phone)}</p> | |
| 72 | ${code === "" ? "" : `<div class="sign-qr">${code}</div>`} | |
| 73 | </footer> | |
| 74 | </article>`, | |
| 75 | who, | |
| 76 | { path: `/listings/${l.id}/sign`, heading: `Open house sign: ${l.address}`, kind: "print" }, | |
| 77 | ); | |
| 78 | } |
src/routes.ts +9
| @@ -94,6 +94,7 @@ import { dayOf } from "./showings.ts"; | ||
| 94 | 94 | import { postcardPage } from "./pages/postcard.ts"; |
| 95 | 95 | import { openHousePage, firstName } from "./pages/openhouse.ts"; |
| 96 | 96 | import { signInPage } from "./pages/signin.ts"; |
| 97 | import { arrowOf, signPage } from "./pages/sign.ts"; | |
| 97 | 98 | import { cardPage } from "./pages/card.ts"; |
| 98 | 99 | import { approval, asideOf, changedLine, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts"; |
| 99 | 100 | import { notesPage } from "./pages/notes.ts"; |
| @@ -695,6 +696,14 @@ export const routes: readonly Route[] = [ | ||
| 695 | 696 | ); |
| 696 | 697 | }, |
| 697 | 698 | }, |
| 699 | { | |
| 700 | method: "GET", | |
| 701 | path: "/listings/:id/sign", | |
| 702 | handle: (c) => | |
| 703 | page(c.who, getListing(c.db, c.who, { id: Number(c.params.id) }), (listing) => | |
| 704 | signPage({ who: c.who, listing, today: dayOf(now()), arrow: arrowOf(new URL(c.request.url).searchParams.get("arrow")), origin: publicOrigin }), | |
| 705 | ), | |
| 706 | }, | |
| 698 | 707 | { |
| 699 | 708 | method: "GET", |
| 700 | 709 | path: "/listings/:id/report", |
src/sign.test.ts added +95
| @@ -0,0 +1,95 @@ | ||
| 1 | /** The open house directional sign: the sample's day and time, the arrows, the notices, escaping, speed. */ | |
| 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(), "sign-")), identityKey: KEY }); | |
| 11 | const base = `http://${server.hostname}:${server.port}`; | |
| 12 | afterAll(() => server.stop(true)); | |
| 13 | ||
| 14 | const identity = `mason-sign|mason-sign|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: "3 Sign St, 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 | ||
| 32 | test("the sample listing's sign says OPEN HOUSE, its day and time, the agent and the QR", async () => { | |
| 33 | const r = await fetch(`${base}/listings/1/sign`); | |
| 34 | expect(r.status).toBe(200); | |
| 35 | const html = await r.text(); | |
| 36 | expect(html).toContain("OPEN HOUSE"); | |
| 37 | expect(html).toContain("Sunday, March 7, 1–3 pm"); | |
| 38 | expect(html).toContain("412 Alder Lane"); | |
| 39 | expect(html).toContain("QR code for the property page"); | |
| 40 | const flyer = await (await fetch(`${base}/listings/1/flyer`)).text(); | |
| 41 | expect(html).toContain(/<path d="([^"]+)" fill="black"/.exec(flyer)![1]!); | |
| 42 | expect(html).not.toContain("<script>"); | |
| 43 | expect(await (await fetch(`${base}/listings/1`)).text()).toContain(`href="/listings/1/sign"`); | |
| 44 | }); | |
| 45 | ||
| 46 | test("each arrow value, and any other, picks the arrow", async () => { | |
| 47 | const arrow = async (q: string) => /aria-label="Arrow pointing (\w+)"/.exec(await (await fetch(`${base}/listings/1/sign${q}`)).text())![1]; | |
| 48 | expect(await arrow("")).toBe("right"); | |
| 49 | expect(await arrow("?arrow=right")).toBe("right"); | |
| 50 | expect(await arrow("?arrow=left")).toBe("left"); | |
| 51 | expect(await arrow("?arrow=up")).toBe("up"); | |
| 52 | expect(await arrow("?arrow=down")).toBe("right"); | |
| 53 | expect(await arrow("?arrow=%3Cb%3E")).toBe("right"); | |
| 54 | expect(await (await fetch(`${base}/listings/1/sign`)).text()).toContain(`href="/listings/1/sign?arrow=left"`); | |
| 55 | }); | |
| 56 | ||
| 57 | test("no open house gets a notice and no link; off the market says so", async () => { | |
| 58 | const id = await launch({}); | |
| 59 | const r = await call(`/listings/${id}/sign`); | |
| 60 | expect(r.status).toBe(200); | |
| 61 | const html = await r.text(); | |
| 62 | expect(html).toContain("Add an open house"); | |
| 63 | expect(html).toContain(`href="/listings/${id}"`); | |
| 64 | expect(html).not.toContain("OPEN HOUSE"); | |
| 65 | expect(await (await call(`/listings/${id}`)).text()).not.toContain(`/listings/${id}/sign"`); | |
| 66 | ||
| 67 | const open = await launch({ open1_date: "2027-03-06", open1_start: "11:00", open1_end: "13:00" }); | |
| 68 | await call(`/listings/${open}/status`, { method: "POST", body: new URLSearchParams({ status: "withdrawn" }) }); | |
| 69 | expect(await (await call(`/listings/${open}/sign`)).text()).toContain("off the market"); | |
| 70 | }); | |
| 71 | ||
| 72 | test("a hostile address and name stay escaped on the sign", async () => { | |
| 73 | const id = await launch({ | |
| 74 | address: `1 <script>alert(1)</script> "St", Portland, OR 97201`, | |
| 75 | agent_name: `<img src=x onerror=alert(1)>`, | |
| 76 | open1_date: "2027-03-06", open1_start: "11:00", open1_end: "13:00", | |
| 77 | }); | |
| 78 | const html = await (await call(`/listings/${id}/sign`)).text(); | |
| 79 | expect(html).toContain("OPEN HOUSE"); | |
| 80 | expect(html).not.toContain("<script>alert(1)"); | |
| 81 | expect(html).not.toContain("<img src=x"); | |
| 82 | expect(html).toContain("<script>"); | |
| 83 | }); | |
| 84 | ||
| 85 | test("the sign answers in under 50 ms at the 95th percentile", async () => { | |
| 86 | await (await fetch(`${base}/listings/1/sign`)).text(); | |
| 87 | const times: number[] = []; | |
| 88 | for (let i = 0; i < 50; i++) { | |
| 89 | const t = performance.now(); | |
| 90 | await (await fetch(`${base}/listings/1/sign`)).text(); | |
| 91 | times.push(performance.now() - t); | |
| 92 | } | |
| 93 | times.sort((a, b) => a - b); | |
| 94 | expect(times[47]).toBeLessThan(50); | |
| 95 | }); |
static/app.css +84
| @@ -1197,6 +1197,90 @@ main:focus { | ||
| 1197 | 1197 | } |
| 1198 | 1198 | } |
| 1199 | 1199 | |
| 1200 | /* The open house directional sign: Letter landscape, one page, the agent's colour. */ | |
| 1201 | @page sign { | |
| 1202 | size: letter landscape; | |
| 1203 | margin: 0; | |
| 1204 | } | |
| 1205 | .sign { | |
| 1206 | max-width: 64rem; | |
| 1207 | margin: 0 auto; | |
| 1208 | aspect-ratio: 11 / 8.5; | |
| 1209 | display: flex; | |
| 1210 | flex-direction: column; | |
| 1211 | overflow: hidden; | |
| 1212 | border: 1px solid currentColor; | |
| 1213 | page: sign; | |
| 1214 | } | |
| 1215 | .sign-title { | |
| 1216 | margin: 0; | |
| 1217 | padding: var(); | |
| 1218 | text-align: center; | |
| 1219 | font-size: clamp(2.5rem, 11vw, 7rem); | |
| 1220 | line-height: 1; | |
| 1221 | -webkit-print-color-adjust: exact; | |
| 1222 | print-color-adjust: exact; | |
| 1223 | } | |
| 1224 | .sign-main { | |
| 1225 | flex: 1; | |
| 1226 | min-height: 0; | |
| 1227 | display: grid; | |
| 1228 | grid-template-columns: 1fr 1fr; | |
| 1229 | gap: var(); | |
| 1230 | align-items: center; | |
| 1231 | padding: var() var(); | |
| 1232 | } | |
| 1233 | .sign-when { | |
| 1234 | margin: 0; | |
| 1235 | font-size: clamp(1.5rem, 5vw, 3.25rem); | |
| 1236 | font-weight: 700; | |
| 1237 | line-height: 1.1; | |
| 1238 | } | |
| 1239 | .sign-address { | |
| 1240 | margin: var() 0 0; | |
| 1241 | font-size: clamp(1rem, 2.5vw, 1.75rem); | |
| 1242 | overflow-wrap: anywhere; | |
| 1243 | } | |
| 1244 | .sign-arrow { | |
| 1245 | display: flex; | |
| 1246 | justify-content: center; | |
| 1247 | } | |
| 1248 | .sign-arrow-svg { | |
| 1249 | width: 100%; | |
| 1250 | max-width: 24rem; | |
| 1251 | max-height: 100%; | |
| 1252 | } | |
| 1253 | .sign-foot { | |
| 1254 | display: flex; | |
| 1255 | align-items: center; | |
| 1256 | justify-content: space-between; | |
| 1257 | gap: var(); | |
| 1258 | padding: var() var() var(); | |
| 1259 | } | |
| 1260 | .sign-agent { | |
| 1261 | margin: 0; | |
| 1262 | font-size: clamp(1.25rem, 3.5vw, 2.25rem); | |
| 1263 | overflow-wrap: anywhere; | |
| 1264 | } | |
| 1265 | .sign-qr .qr { | |
| 1266 | display: block; | |
| 1267 | width: 1.4in; | |
| 1268 | height: 1.4in; | |
| 1269 | } | |
| 1270 | .sign-notice { | |
| 1271 | max-width: 40rem; | |
| 1272 | margin: 0 auto; | |
| 1273 | } | |
| 1274 | @media print { | |
| 1275 | .sign { | |
| 1276 | max-width: none; | |
| 1277 | width: 11in; | |
| 1278 | height: 8.5in; | |
| 1279 | aspect-ratio: auto; | |
| 1280 | border: 0; | |
| 1281 | } | |
| 1282 | } | |
| 1283 | ||
| 1200 | 1284 | /* The launch week: days in order, each step with its asset and approval; no controls on paper. */ |
| 1201 | 1285 | .schedule { |
| 1202 | 1286 | max-width: 48rem; |