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
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/43-print-an-open-house-sign-in-sheet-for-a → main 1 commit fork
8 files
changed +185
−1
README.md +5
| @@ -52,6 +52,11 @@ to make a listing of your own. | ||
| 52 | 52 | post a closing line, such as "Open Sunday, March 7, 1–3 pm", all in the |
| 53 | 53 | listing's tone. Clearing the three boxes takes it off again. The times are |
| 54 | 54 | 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. | |
| 55 | 60 | 9. **Mark it sold.** On your listing, pick **Sold** in the status box beside |
| 56 | 61 | the tone, add the price it sold at if you want it said, and press **Set |
| 57 | 62 | 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: | ||
| 5 | 5 | - `flyer-open` — the band under the price on the flyer (`static/app.css`); it also carries `brand-fill`, the agent's colour. |
| 6 | 6 | - `listing-open` — the open house line under the facts in a listing's head. |
| 7 | 7 | - `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); | ||
| 42 | 42 | for (const [k, v] of Object.entries({ beds: 3, baths: 2, sqft: 1800, lot: "0.2 <acre>", year: 1999, |
| 43 | 43 | price: 500000, tone: "classic", palette: "navy", agent_phone: "+1 (503) 555-0100" })) |
| 44 | 44 | 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"); | |
| 45 | 48 | form.set("features", [1, 2, 3, 4, 5].map((n) => `${PAYLOADS.feature} ${n}`).join("\n")); |
| 46 | 49 | form.set("agent_name", PAYLOADS.agent_name); |
| 47 | 50 | form.set("brokerage", PAYLOADS.brokerage); |
| @@ -90,7 +93,7 @@ const expectInert = (html: string) => { | ||
| 90 | 93 | expect(html).not.toMatch(/<textarea[^>]*>(?:(?!<\/textarea>)[^])*<\/textarea>[^]*?<script>alert/); |
| 91 | 94 | }; |
| 92 | 95 | |
| 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"]) { | |
| 94 | 97 | test(`${path} escapes every typed word`, async () => { |
| 95 | 98 | const r = await call(path.replace(":id", String(id))); |
| 96 | 99 | expect(r.status).toBe(200); |
src/pages/listing.ts +1
| @@ -243,6 +243,7 @@ export function listingPage(input: { | ||
| 243 | 243 | ${editable ? statusForm(l) : ""} |
| 244 | 244 | ${editable ? `<a class="button" href="/listings/${l.id}/facts">Edit facts</a>` : ""} |
| 245 | 245 | <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>`} | |
| 246 | 247 | <a class="button button-quiet" href="/listings/${l.id}/report">Seller report</a> |
| 247 | 248 | <a class="button button-quiet" href="/listings/${l.id}/kit.txt" download>Download launch kit</a> |
| 248 | 249 | <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"; | ||
| 72 | 72 | import { propertyPage } from "./pages/property.ts"; |
| 73 | 73 | import { isPost, lengthLine } from "./postlength.ts"; |
| 74 | 74 | import { reportPage } from "./pages/report.ts"; |
| 75 | import { signInPage } from "./pages/signin.ts"; | |
| 75 | 76 | import { approval, asideOf, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts"; |
| 76 | 77 | import { notesPage } from "./pages/notes.ts"; |
| 77 | 78 | import { pathOf, PHOTOS_MAX, PHOTOS_TOTAL, typeOf } from "./photos.ts"; |
| @@ -552,6 +553,19 @@ export const routes: readonly Route[] = [ | ||
| 552 | 553 | flyerPage({ who: c.who, listing, origin: publicOrigin }), |
| 553 | 554 | ), |
| 554 | 555 | }, |
| 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 | }, | |
| 555 | 569 | { |
| 556 | 570 | method: "GET", |
| 557 | 571 | 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 { | ||
| 1114 | 1114 | max-height: 3in; |
| 1115 | 1115 | } |
| 1116 | 1116 | } |
| 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(); | |
| 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() 0; | |
| 1142 | padding: var() var(); | |
| 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(); | |
| 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 | } |