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

Merged opened this

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

Merged

mason/listing-launch:mason/135-print-an-open-house-directional-sign-for → main 2 commits fork

  • Merged. mason/135-print-an-open-house-directional-sign-for was merged into main as 957b843.

Review

Sign in to review this change.
7 files changed +275 −0
README.md +7
@@ -63,6 +63,13 @@ to make a listing of your own.
6363 times, your name and phone in your colour, and fifteen blank rows for name,
6464 email, phone and whether the visitor works with an agent. Print it; nothing
6565 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.
6673 Beside it, **Sign-in screen for a tablet** (`/listings/<id>/open-house`, on
6774 *412 Alder Lane* too) is for handing the device to visitors at the door,
6875 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:
77 - `open-grid` — the form's date / start / end row, a `facts-grid` with the same columns.
88 - `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`.
99 - `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: {
330330 <a class="button" href="/listings/${l.id}/flyer">Print flyer</a>
331331 <a class="button button-quiet" href="/listings/${l.id}/postcard">Postcards</a>
332332 ${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>`}
333334 ${!editable ? "" : `<a class="button button-quiet" href="/listings/${l.id}/open-house">Sign-in screen for a tablet</a>`}
334335 <a class="button button-quiet" href="/listings/${l.id}/report">Seller report</a>
335336 <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";
9494 import { postcardPage } from "./pages/postcard.ts";
9595 import { openHousePage, firstName } from "./pages/openhouse.ts";
9696 import { signInPage } from "./pages/signin.ts";
97 import { arrowOf, signPage } from "./pages/sign.ts";
9798 import { cardPage } from "./pages/card.ts";
9899 import { approval, asideOf, changedLine, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts";
99100 import { notesPage } from "./pages/notes.ts";
@@ -695,6 +696,14 @@ export const routes: readonly Route[] = [
695696 );
696697 },
697698 },
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 },
698707 {
699708 method: "GET",
700709 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("&lt;script&gt;");
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 {
11971197 }
11981198 }
11991199
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(--space-3);
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(--space-4);
1230 align-items: center;
1231 padding: var(--space-3) var(--space-4);
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(--space-2) 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(--space-3);
1258 padding: var(--space-2) var(--space-4) var(--space-3);
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
12001284 /* The launch week: days in order, each step with its asset and approval; no controls on paper. */
12011285 .schedule {
12021286 max-width: 48rem;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs