Public: anyone can read this; the crew can change it.

Print an open house sign-in sheet for a listing with an open house, in the agent's brand #65

Merged opened this

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

Merged

mason/listing-launch:mason/43-print-an-open-house-sign-in-sheet-for-a → main 1 commit fork

  • Merged. mason/43-print-an-open-house-sign-in-sheet-for-a was merged into main as 7bc1a94.

Review

Sign in to review this change.
8 files changed +185 −1
README.md +5
@@ -52,6 +52,11 @@ to make a listing of your own.
5252 post a closing line, such as "Open Sunday, March 7, 1–3 pm", all in the
5353 listing's tone. Clearing the three boxes takes it off again. The times are
5454 kept as you type them: no time zone is applied.
55 A listing with an open house also shows a **Sign-in sheet** link (and
56 `/listings/<id>/sign-in`): one Letter page with the photo, price, open house
57 times, your name and phone in your colour, and fifteen blank rows for name,
58 email, phone and whether the visitor works with an agent. Print it; nothing
59 typed on paper is stored. A listing with no open house answers 404 there.
5560 9. **Mark it sold.** On your listing, pick **Sold** in the status box beside
5661 the tone, add the price it sold at if you want it said, and press **Set
5762 status**. Two more assets appear, a status post under Social and a status
docs/design/areas/open-house.md +1
@@ -5,3 +5,4 @@ New component classes, all built from existing tokens:
55 - `flyer-open` — the band under the price on the flyer (`static/app.css`); it also carries `brand-fill`, the agent's colour.
66 - `listing-open` — the open house line under the facts in a listing's head.
77 - `open-grid` — the form's date / start / end row, a `facts-grid` with the same columns.
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`.
src/hostile.test.ts +4 −1
@@ -42,6 +42,9 @@ form.set("address", PAYLOADS.address);
4242 for (const [k, v] of Object.entries({ beds: 3, baths: 2, sqft: 1800, lot: "0.2 <acre>", year: 1999,
4343 price: 500000, tone: "classic", palette: "navy", agent_phone: "+1 (503) 555-0100" }))
4444 form.set(k, String(v));
45 form.set("open1_date", "2027-03-07");
46 form.set("open1_start", "13:00");
47 form.set("open1_end", "15:00");
4548 form.set("features", [1, 2, 3, 4, 5].map((n) => `${PAYLOADS.feature} ${n}`).join("\n"));
4649 form.set("agent_name", PAYLOADS.agent_name);
4750 form.set("brokerage", PAYLOADS.brokerage);
@@ -90,7 +93,7 @@ const expectInert = (html: string) => {
9093 expect(html).not.toMatch(/<textarea[^>]*>(?:(?!<\/textarea>)[^])*<\/textarea>[^]*?<script>alert/);
9194 };
9295
93 for (const path of ["/", "/listings/:id", "/listings/:id/flyer", "/listings/:id/report", "/listings/:id/email", "/homes/:id"]) {
96 for (const path of ["/", "/listings/:id", "/listings/:id/flyer", "/listings/:id/report", "/listings/:id/sign-in", "/listings/:id/email", "/homes/:id"]) {
9497 test(`${path} escapes every typed word`, async () => {
9598 const r = await call(path.replace(":id", String(id)));
9699 expect(r.status).toBe(200);
src/pages/listing.ts +1
@@ -243,6 +243,7 @@ export function listingPage(input: {
243243 ${editable ? statusForm(l) : ""}
244244 ${editable ? `<a class="button" href="/listings/${l.id}/facts">Edit facts</a>` : ""}
245245 <a class="button" href="/listings/${l.id}/flyer">Print flyer</a>
246 ${whenLine === null ? "" : `<a class="button button-quiet" href="/listings/${l.id}/sign-in">Sign-in sheet</a>`}
246247 <a class="button button-quiet" href="/listings/${l.id}/report">Seller report</a>
247248 <a class="button button-quiet" href="/listings/${l.id}/kit.txt" download>Download launch kit</a>
248249 <a class="button button-quiet" href="/?from=${l.id}">Start a new one from this</a>
src/pages/signin.ts added +50
@@ -0,0 +1,50 @@
1 /**
2 * The open house sign-in sheet at `/listings/:id/sign-in`: blank paper for
3 * visitors at the door, in the agent's colour. Nothing typed on it is stored.
4 */
5
6 import { money, openHouseLine } from "../generate.ts";
7 import { escape } from "../http.ts";
8 import type { Launched } from "../listing.ts";
9 import type { Who } from "../who.ts";
10 import { layout } from "./layout.ts";
11 import { photoUrl } from "./listing.ts";
12
13 export const SIGN_IN_ROWS = 15;
14
15 const blankRows = Array.from({ length: SIGN_IN_ROWS }, () => `<tr><td></td><td></td><td></td><td></td></tr>`).join("\n ");
16
17 /** The sheet, or null when the listing has no open house to sign in to. */
18 export function signInPage(input: { who: Who; listing: Launched }): string | null {
19 const { who, listing: l } = input;
20 const when = openHouseLine(l);
21 if (when === null) return null;
22 const hero = l.photos[0];
23 return layout(
24 `<div class="print-bar row">
25 <a href="/listings/${l.id}">← Back to the listing</a>
26 <button type="button" class="button" data-print hidden>Print</button>
27 <span class="muted">Letter size, one page.</span>
28 </div>
29 <article class="signin palette-${escape(l.palette)}">
30 <header class="signin-head">
31 ${hero === undefined ? "" : `<img class="signin-hero" src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="1200" height="800">`}
32 <div>
33 <h2 class="signin-address">${escape(l.address)}</h2>
34 <p class="signin-price brand">${money(l.price)}</p>
35 <p class="signin-open brand-fill">${escape(when)}</p>
36 <p class="signin-agent"><b class="brand">${escape(l.agent_name)}</b> · ${escape(l.agent_phone)}</p>
37 </div>
38 </header>
39 <h3>Open house sign-in</h3>
40 <table class="signin-table">
41 <thead><tr><th scope="col">Name</th><th scope="col">Email</th><th scope="col">Phone</th><th scope="col">Working with an agent? (Yes / No)</th></tr></thead>
42 <tbody>
43 ${blankRows}
44 </tbody>
45 </table>
46 </article>`,
47 who,
48 { path: `/listings/${l.id}/sign-in`, heading: `Sign-in sheet: ${l.address}`, kind: "print" },
49 );
50 }
src/routes.ts +14
@@ -72,6 +72,7 @@ import { publicOrigin } from "./env.ts";
7272 import { propertyPage } from "./pages/property.ts";
7373 import { isPost, lengthLine } from "./postlength.ts";
7474 import { reportPage } from "./pages/report.ts";
75 import { signInPage } from "./pages/signin.ts";
7576 import { approval, asideOf, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts";
7677 import { notesPage } from "./pages/notes.ts";
7778 import { pathOf, PHOTOS_MAX, PHOTOS_TOTAL, typeOf } from "./photos.ts";
@@ -552,6 +553,19 @@ export const routes: readonly Route[] = [
552553 flyerPage({ who: c.who, listing, origin: publicOrigin }),
553554 ),
554555 },
556 {
557 method: "GET",
558 path: "/listings/:id/sign-in",
559 handle: (c) => {
560 const found = getListing(c.db, c.who, { id: Number(c.params.id) });
561 const sheet = found.ok ? signInPage({ who: c.who, listing: found.value }) : null;
562 return page(
563 c.who,
564 found.ok && sheet === null ? { ok: false, error: "not_found", message: "This listing has no open house." } : found,
565 () => sheet ?? "",
566 );
567 },
568 },
555569 {
556570 method: "GET",
557571 path: "/listings/:id/report",
src/signin.test.ts added +54
@@ -0,0 +1,54 @@
1 /** The open house sign-in sheet: linked only when there is an open house, fifteen blank rows. */
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(), "signin-")), 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: "7 Sign-in 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 const made = await call("/listings", { method: "POST", body: form });
28 return /\/listings\/(\d+)/.exec(made.headers.get("location")!)![1];
29 };
30
31 test("with two open houses the sheet carries both, the agent and 15 blank rows", async () => {
32 const id = await launch({
33 open1_date: "2027-03-06", open1_start: "11:00", open1_end: "13:30",
34 open2_date: "2027-03-07", open2_start: "13:00", open2_end: "15:00",
35 });
36 expect(await (await call(`/listings/${id}`)).text()).toContain(`href="/listings/${id}/sign-in"`);
37 const r = await call(`/listings/${id}/sign-in`);
38 expect(r.status).toBe(200);
39 const html = await r.text();
40 expect(html).toContain("Saturday, March 6");
41 expect(html).toContain("Sunday, March 7");
42 expect(html).toContain("Pat Agent");
43 expect(html).toContain("+1 (503) 555-0100");
44 expect(html).toContain("Working with an agent?");
45 expect(html.match(/<tbody>[^]*<\/tbody>/)![0].match(/<tr>/g)?.length).toBe(15);
46 expect(html).toContain("data-print");
47 });
48
49 test("without an open house there is no link and the sheet is 404", async () => {
50 const id = await launch({});
51 expect(await (await call(`/listings/${id}`)).text()).not.toContain("/sign-in");
52 expect((await call(`/listings/${id}/sign-in`)).status).toBe(404);
53 expect((await call(`/listings/9999/sign-in`)).status).toBe(404);
54 });
static/app.css +56
@@ -1114,3 +1114,59 @@ main:focus {
11141114 max-height: 3in;
11151115 }
11161116 }
1117
1118 /* The open house sign-in sheet: one Letter page of blank rows, no controls on paper. */
1119 .signin {
1120 max-width: 48rem;
1121 margin: 0 auto;
1122 }
1123 .signin-head {
1124 display: grid;
1125 grid-template-columns: 1fr 1fr;
1126 gap: var(--space-3);
1127 align-items: center;
1128 }
1129 .signin-hero {
1130 display: block;
1131 width: 100%;
1132 height: auto;
1133 aspect-ratio: 3 / 2;
1134 object-fit: cover;
1135 }
1136 .signin-address,
1137 .signin-price {
1138 margin: 0;
1139 }
1140 .signin-open {
1141 margin: var(--space-2) 0;
1142 padding: var(--space-2) var(--space-3);
1143 -webkit-print-color-adjust: exact;
1144 print-color-adjust: exact;
1145 }
1146 .signin-agent {
1147 margin: 0;
1148 }
1149 .signin-table {
1150 width: 100%;
1151 border-collapse: collapse;
1152 table-layout: fixed;
1153 }
1154 .signin-table th {
1155 text-align: left;
1156 padding: var(--space-2);
1157 border-bottom: 2px solid currentColor;
1158 }
1159 .signin-table td {
1160 height: 0.42in;
1161 border-bottom: 1px solid currentColor;
1162 }
1163 @media print {
1164 .signin {
1165 max-width: none;
1166 margin: 0.5in;
1167 break-inside: avoid;
1168 }
1169 .signin-hero {
1170 max-height: 2.2in;
1171 }
1172 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs