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

Hand a tablet to open house visitors: a full-screen sign-in page where each visitor types their name and email, sees a thank-you, and never sees who signed in before them #127

Merged opened this

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

Merged

mason/listing-launch:mason/125-hand-a-tablet-to-open-house-visitors-a-f → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/125-hand-a-tablet-to-open-house-visitors-a-f was merged into main as 19a6d8c.

Review

Sign in to review this change.
13 files changed +259 −6
README.md +9
@@ -63,6 +63,15 @@ 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, **Sign-in screen for a tablet** (`/listings/<id>/open-house`, on
67 *412 Alder Lane* too) is for handing the device to visitors at the door,
68 on your own signed-in session: your name and colour, the address and
69 photo, a name, an email and one big **Sign in** button. Each visitor goes
70 into the follow-up list (same rules as typing them in), sees "Thanks for
71 stopping by, <first name>" for a few seconds, and never sees anyone who
72 signed in before; a bad email shows in place with the name kept. Only you
73 or an admin can open it, it shows no other link than a small **Done** back
74 to the listing, and it works for a private showing with no open house.
6675 A listing with an open house also gets an **Open house follow-up email**
6776 (Email tab, "Preview follow-up" on the email page): a thanks in the
6877 listing's tone with the facts, the property page link and your signature.
docs/design/areas/open-house.md +1
@@ -6,3 +6,4 @@ New component classes, all built from existing tokens:
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.
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`.
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`.
src/actions.ts −1
@@ -1484,7 +1484,6 @@ export const addVisitor = (
14841484 function putVisitor(db: Database, who: Who, input: { id: number; name: unknown; email: unknown }): Result<{ id: number }> {
14851485 const found = visitorsFor(db, who, input.id, "Sign in to add a visitor.");
14861486 if (!found.ok) return found;
1487 if (found.value.open_houses.length === 0) return fail("invalid", "This listing has no open house.");
14881487 const valid = validVisitor(input);
14891488 if (!valid.ok) return fail("invalid", valid.message);
14901489 const have = db.query<{ n: number }, [number]>("SELECT count(*) AS n FROM visitor WHERE listing_id = ?1").get(found.value.id)!.n;
src/followup.test.ts +3 −2
@@ -104,7 +104,7 @@ test("a hostile visitor name is stored as typed, shown escaped, and only to the
104104 expect(JSON.stringify(read(db, id))).not.toContain("[email protected]");
105105 });
106106
107 test("visitors are validated, need an open house, and can be removed", () => {
107 test("visitors are validated, need no open house, and can be removed", () => {
108108 const { db, id } = launch();
109109 for (const bad of [
110110 { name: "", email: "[email protected]" },
@@ -125,7 +125,8 @@ test("visitors are validated, need an open house, and can be removed", () => {
125125
126126 const bare = launch([]);
127127 const none = addVisitor(bare.db, me, { id: bare.id, name: "Ann", email: "[email protected]" });
128 expect(!none.ok && none.error).toBe("invalid");
128 // A private showing has no open house, and its visitors still sign in (the tablet screen).
129 expect(none.ok).toBe(true);
129130 });
130131
131132 test("the pages: the follow-up shows with its visitors to the author, a stranger sees none, and a cross-site add is refused", async () => {
src/hostile.test.ts +13 −1
@@ -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/postcard", "/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/open-house", "/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);
@@ -107,6 +107,18 @@ for (const path of ["/", "/listings/:id", "/listings/:id/flyer", "/listings/:id/
107107 });
108108 }
109109
110 test("the tablet screen's 422 re-render escapes the typed name", async () => {
111 const r = await call(`/listings/${id}/open-house`, {
112 method: "POST",
113 headers: { "content-type": "application/x-www-form-urlencoded" },
114 body: new URLSearchParams({ name: `"><img src=x onerror=alert(9)><script>alert(9)</script>`, email: "nope" }),
115 });
116 expect(r.status).toBe(422);
117 const html = await r.text();
118 expectInert(html);
119 expect(html).toContain(`value="&quot;&gt;&lt;img src=x onerror=alert(9)&gt;`);
120 });
121
110122 test("share and Open Graph tags escape the listing's words", async () => {
111123 for (const path of [`/listings/${id}`, `/listings/${id}/email`, `/listings/${id}/flyer`, `/listings/${id}/postcard`, `/homes/${id}`]) {
112124 const html = await (await call(path)).text();
src/pages/layout.ts +4 −2
@@ -42,6 +42,8 @@ export interface Frame {
4242 readonly contact: string;
4343 readonly palette: string;
4444 readonly back?: string;
45 /** A tablet at the door: the way back reads "Done", and no link leaves for another page. */
46 readonly kiosk?: boolean;
4547 };
4648 }
4749
@@ -82,7 +84,7 @@ export function layout(body: string, who: Who, frame: Frame = {}): string {
8284 </header>`
8385 : `<header class="head public-head palette-${escape(a.palette)}">
8486 <p class="public-brand brand"><b>${escape(a.name)}</b> · ${escape(a.brokerage)}</p>${a.back === undefined ? "" : `
85 <a class="public-back muted" href="${escape(a.back)}">Back to the listing</a>`}
87 <a class="public-back muted" href="${escape(a.back)}">${a.kiosk === true ? "Done" : "Back to the listing"}</a>`}
8688 </header>`;
8789 const foot =
8890 a === undefined
@@ -94,7 +96,7 @@ export function layout(body: string, who: Who, frame: Frame = {}): string {
9496 : `<footer class="muted public-foot">
9597 <p>Equal Housing Opportunity</p>
9698 <p>${escape(a.contact)}</p>
97 <p><small><a href="/__forge/report">Report a problem</a></small></p>
99 ${a.kiosk === true ? "" : `<p><small><a href="/__forge/report">Report a problem</a></small></p>`}
98100 </footer>`;
99101 return `<!doctype html>
100102 <html lang="en">
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 ${!editable ? "" : `<a class="button button-quiet" href="/listings/${l.id}/open-house">Sign-in screen for a tablet</a>`}
333334 <a class="button button-quiet" href="/listings/${l.id}/report">Seller report</a>
334335 <a class="button button-quiet" href="/listings/${l.id}/schedule">Launch week</a>
335336 <a class="button button-quiet" href="/listings/${l.id}/kit.txt" download>Download launch kit</a>
src/pages/openhouse.ts added +50
@@ -0,0 +1,50 @@
1 /**
2 * The sign-in screen for a tablet, at `/listings/:id/open-house`: the agent's
3 * brand, the address and photo, a name, an email and one big button. It shows
4 * no visitor already signed in and leads nowhere but a small "Done".
5 */
6
7 import { escape } from "../http.ts";
8 import type { Launched } from "../listing.ts";
9 import type { Who } from "../who.ts";
10 import { layout, toast } from "./layout.ts";
11 import { photoUrl } from "./listing.ts";
12
13 /** The first word of a name, for the thank-you. */
14 export const firstName = (name: string): string => name.split(" ")[0] ?? name;
15
16 export function openHousePage(input: {
17 who: Who;
18 listing: Launched;
19 /** The one-time thank-you a redirect left. */
20 notice?: string | undefined;
21 /** What went wrong, and the name that was typed, kept for another go. */
22 error?: string;
23 name?: string;
24 }): string {
25 const { who, listing: l } = input;
26 const hero = l.photos[0];
27 return layout(
28 `<article class="kiosk palette-${escape(l.palette)}">
29 ${hero === undefined ? "" : `<img class="home-hero" src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="1200" height="800">`}
30 <h2>${escape(l.address)}</h2>
31 <p class="kiosk-ask brand">Welcome! Please sign in.</p>
32 ${input.error === undefined ? "" : `<p class="card kiosk-error" role="alert">${escape(input.error)}</p>`}
33 <form class="kiosk-form" method="post" action="/listings/${l.id}/open-house">
34 <label class="field">Your name
35 <input class="input kiosk-input" name="name" value="${escape(input.name ?? "")}" autocomplete="off" maxlength="80" required>
36 </label>
37 <label class="field">Your email
38 <input class="input kiosk-input" name="email" type="email" inputmode="email" autocomplete="off" maxlength="120" required>
39 </label>
40 <button class="button kiosk-go brand-fill">Sign in</button>
41 </form>
42 </article>${toast(input.notice)}`,
43 who,
44 {
45 path: `/listings/${l.id}/open-house`,
46 heading: l.address,
47 agent: { name: l.agent_name, brokerage: l.brokerage, contact: `${l.agent_phone} · ${l.agent_email}`, palette: l.palette, back: `/listings/${l.id}`, kiosk: true },
48 },
49 );
50 }
src/pages/signin.ts +1
@@ -26,6 +26,7 @@ export function signInPage(input: { who: Who; listing: Launched; origin?: string
2626 <a href="/listings/${l.id}">← Back to the listing</a>
2727 <button type="button" class="button" data-print hidden>Print</button>
2828 <span class="muted">Letter size, one page.</span>
29 <a href="/listings/${l.id}/open-house">Sign-in screen for a tablet</a>
2930 </div>
3031 <article class="signin palette-${escape(l.palette)}">
3132 <header class="signin-head">
src/routes.ts +33
@@ -91,6 +91,7 @@ import { weekPage } from "./pages/week.ts";
9191 import { now } from "./clock.ts";
9292 import { dayOf } from "./showings.ts";
9393 import { postcardPage } from "./pages/postcard.ts";
94 import { openHousePage, firstName } from "./pages/openhouse.ts";
9495 import { signInPage } from "./pages/signin.ts";
9596 import { cardPage } from "./pages/card.ts";
9697 import { approval, asideOf, changedLine, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts";
@@ -648,6 +649,38 @@ export const routes: readonly Route[] = [
648649 handle: (c) =>
649650 page(c.who, getListing(c.db, c.who, { id: Number(c.params.id) }), (listing) => cardPage({ who: c.who, listing })),
650651 },
652 {
653 method: "GET",
654 path: "/listings/:id/open-house",
655 handle: (c) => {
656 const id = Number(c.params.id);
657 const { notice, clear } = readNotice(c.request);
658 const allowed = listVisitors(c.db, c.who, { id });
659 const found = allowed.ok ? getListing(c.db, c.who, { id }) : allowed;
660 return found.ok
661 ? html(openHousePage({ who: c.who, listing: found.value, notice }), 200, { ...clear, "cache-control": "no-store" })
662 : page(c.who, found, () => "");
663 },
664 },
665 {
666 method: "POST",
667 path: "/listings/:id/open-house",
668 handle: async (c) => {
669 const form = await readForm(c.request);
670 if (form === undefined) return text("too large", 413);
671 const id = Number(c.params.id);
672 const done = addVisitor(c.db, c.who, { id, name: form.get("name"), email: form.get("email") });
673 if (done.ok) {
674 const first = firstName(String(form.get("name") ?? "").replace(/\s+/g, " ").trim());
675 return back(`Thanks for stopping by, ${first}`, `/listings/${id}/open-house`);
676 }
677 if (done.error !== "invalid") return page(c.who, done, () => "");
678 const found = getListing(c.db, c.who, { id });
679 return found.ok
680 ? html(openHousePage({ who: c.who, listing: found.value, error: done.message, name: String(form.get("name") ?? "") }), 422, { "cache-control": "no-store" })

Stray blank line before the closing brace; remove it. Also cover this POST with a cross-site-origin test that asserts the refusal and that no visitor was added.

681 : page(c.who, found, () => "");
682 },
683 },
651684 {
652685 method: "GET",
653686 path: "/listings/:id/sign-in",
src/tablet.test.ts added +117
@@ -0,0 +1,117 @@
1 /** The sign-in screen for a tablet: the agent's session, one visitor at a time, nobody else's name. */
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(), "tablet-")), identityKey: KEY });
11 const base = `http://${server.hostname}:${server.port}`;
12 afterAll(() => server.stop(true));
13
14 const as = (id: string | null): Record<string, string> => {
15 if (id === null) return {};
16 const identity = `${id}|${id}|user|${Date.now() + 600_000}`;
17 return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
18 };
19 const call = (id: string | null, path: string, init: RequestInit = {}) =>
20 fetch(base + path, { redirect: "manual", ...init, headers: { ...as(id), ...(init.headers as object) } });
21
22 const launch = async (extra: Record<string, string>, who = "pat"): Promise<string> => {
23 const form = new FormData();
24 for (const [k, v] of Object.entries({
25 address: "9 Tablet St, Portland, OR 97201", beds: 3, baths: 2, sqft: 1800, year: 1999, price: 500000,
26 tone: "classic", palette: "teal", agent_name: "Pat Agent", brokerage: "Harbor Realty", license: "OR-1",
27 agent_phone: "+1 (503) 555-0100", agent_email: "[email protected]", lot: "0.2 acre",
28 features: "Sunny kitchen\nQuiet street\nNew roof\nBig deck\nOak floors", ...extra,
29 }))
30 form.set(k, String(v));
31 form.set("again", "1");
32 const made = await call(who, "/listings", { method: "POST", body: form });
33 return /\/listings\/(\d+)/.exec(made.headers.get("location")!)![1];
34 };
35 const post = (id: string, who: string | null, name: string, email: string) =>
36 call(who, `/listings/${id}/open-house`, {
37 method: "POST",
38 headers: { "content-type": "application/x-www-form-urlencoded" },
39 body: new URLSearchParams({ name, email }),
40 });
41 const OPEN = { open1_date: "2027-03-07", open1_start: "13:00", open1_end: "15:00" };
42
43 test("the agent gets the screen: brand, address, form, no tool header and no way out but Done", async () => {
44 const id = await launch(OPEN);
45 const r = await call("pat", `/listings/${id}/open-house`);
46 expect(r.status).toBe(200);
47 const html = await r.text();
48 expect(html).toContain("9 Tablet St");
49 expect(html).toContain("Pat Agent");
50 expect(html).toContain('name="email"');
51 expect(html).toContain(">Sign in</button>");
52 expect(html).not.toContain("brandmark");
53 expect(html).not.toContain("Sign out");
54 const links = [...html.matchAll(/<a [^>]*href="([^"]+)"/g)].map((m) => m[1]).filter((h) => h !== "#main");
55 expect(links).toEqual([`/listings/${id}`]);
56 expect(html).toContain(">Done</a>");
57 expect(await (await call("pat", `/listings/${id}`)).text()).toContain("Sign-in screen for a tablet");
58 expect(await (await call("pat", `/listings/${id}/sign-in`)).text()).toContain(`/listings/${id}/open-house`);
59 });
60
61 test("a guest and another user are refused, on the page and on the post", async () => {
62 const id = await launch(OPEN);
63 expect((await call(null, `/listings/${id}/open-house`)).status).toBe(401);
64 expect((await call("sam", `/listings/${id}/open-house`)).status).toBe(403);
65 expect((await post(id, null, "Eve", "[email protected]")).status).toBe(401);
66 expect((await post(id, "sam", "Eve", "[email protected]")).status).toBe(403);
67 });
68
69 test("a post adds a visitor the follow-up lists, and the thank-you shows no earlier visitor", async () => {
70 const id = await launch(OPEN);
71 expect((await post(id, "pat", "Ann Early", "[email protected]")).status).toBe(303);
72 const r = await post(id, "pat", "Bo <b>Lee</b>", "[email protected]");
73 expect(r.status).toBe(303);
74 expect(r.headers.get("location")).toBe(`/listings/${id}/open-house`);
75 const cookie = r.headers.get("set-cookie")!.split(";")[0];
76 const html = await (await call("pat", `/listings/${id}/open-house`, { headers: { cookie } })).text();
77 expect(html).toContain("Thanks for stopping by, Bo");
78 expect(html).not.toContain("<b>Lee");
79 expect(html).not.toContain("[email protected]");
80 expect(html).not.toContain("Ann Early");
81 expect(html).not.toContain("[email protected]");
82 const follow = await (await call("pat", `/listings/${id}/email?which=followup`)).text();
83 expect(follow).toContain("[email protected]");
84 expect(follow).toContain("[email protected]");
85 });
86
87 test("a bad email shows in place and keeps the name; no open house still signs in", async () => {
88 const id = await launch({});
89 const r = await post(id, "pat", "Cy Quote", "not-an-email");
90 expect(r.status).toBe(422);
91 const html = await r.text();
92 expect(html).toContain("looks like [email protected]");
93 expect(html).toContain('value="Cy Quote"');
94 expect((await post(id, "pat", "Cy Quote", "[email protected]")).status).toBe(303);
95 expect(await (await call("pat", `/listings/${id}/visitors.csv`)).text()).toContain("[email protected]");
96 });
97
98 test("the screen answers fast", async () => {
99 const id = await launch(OPEN);
100 await call("pat", `/listings/${id}/open-house`);
101 const t = performance.now();
102 await call("pat", `/listings/${id}/open-house`);
103 expect(performance.now() - t).toBeLessThan(50);
104 });
105
106 test("a cross-site POST is refused and adds no visitor", async () => {
107 const id = await launch(OPEN, "quinn");
108 for (const headers of [{ origin: "https://evil.example" }, { "sec-fetch-site": "cross-site" }]) {
109 const r = await call("quinn", `/listings/${id}/open-house`, {
110 method: "POST",
111 headers: { "content-type": "application/x-www-form-urlencoded", ...headers },
112 body: new URLSearchParams({ name: "Mal", email: "[email protected]" }),
113 });
114 expect(r.status).toBe(403);
115 }
116 expect(await (await call("quinn", `/listings/${id}/visitors.csv`)).text()).not.toContain("[email protected]");
117 });
static/app.css +20
@@ -1449,3 +1449,23 @@ main:focus {
14491449 .public-foot {
14501450 text-align: center;
14511451 }
1452
1453 /* The tablet at the door: big, plain, one task. */
1454 .kiosk {
1455 max-width: 40rem;
1456 margin: 0 auto;
1457 }
1458 .kiosk-ask {
1459 font-size: var(--text-lg, 1.125rem);
1460 font-weight: 600;
1461 }
1462 .kiosk-form .kiosk-input {
1463 min-height: calc(var(--size-tap) * 1.25);
1464 font-size: 1.25rem;
1465 }
1466 .kiosk-go {
1467 width: 100%;
1468 justify-content: center;
1469 min-height: calc(var(--size-tap) * 1.5);
1470 font-size: 1.5rem;
1471 }
static/listing.js +7
@@ -190,3 +190,10 @@ for (const button of document.querySelectorAll("[data-copy-subject]")) {
190190 setTimeout(() => (button.textContent = "Copy subject"), 1500);
191191 });
192192 }
193
194 // The tablet's thank-you goes at the next tap or key, not only when it fades.
195 if (document.querySelector(".kiosk") && document.querySelector(".toast")) {
196 const clear = () => document.querySelector(".toast")?.remove();
197 addEventListener("pointerdown", clear, { once: true });
198 addEventListener("keydown", clear, { once: true });
199 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs