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
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/125-hand-a-tablet-to-open-house-visitors-a-f → main 3 commits fork 1 thread · 1 unresolved
13 files
changed +259
−6
README.md +9
| @@ -63,6 +63,15 @@ 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, **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. | |
| 66 | 75 | A listing with an open house also gets an **Open house follow-up email** |
| 67 | 76 | (Email tab, "Preview follow-up" on the email page): a thanks in the |
| 68 | 77 | 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: | ||
| 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 | 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 | - `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 = ( | ||
| 1484 | 1484 | function putVisitor(db: Database, who: Who, input: { id: number; name: unknown; email: unknown }): Result<{ id: number }> { |
| 1485 | 1485 | const found = visitorsFor(db, who, input.id, "Sign in to add a visitor."); |
| 1486 | 1486 | if (!found.ok) return found; |
| 1487 | if (found.value.open_houses.length === 0) return fail("invalid", "This listing has no open house."); | |
| 1488 | 1487 | const valid = validVisitor(input); |
| 1489 | 1488 | if (!valid.ok) return fail("invalid", valid.message); |
| 1490 | 1489 | 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 | ||
| 104 | 104 | expect(JSON.stringify(read(db, id))).not.toContain("[email protected]"); |
| 105 | 105 | }); |
| 106 | 106 | |
| 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", () => { | |
| 108 | 108 | const { db, id } = launch(); |
| 109 | 109 | for (const bad of [ |
| 110 | 110 | { name: "", email: "[email protected]" }, |
| @@ -125,7 +125,8 @@ test("visitors are validated, need an open house, and can be removed", () => { | ||
| 125 | 125 | |
| 126 | 126 | const bare = launch([]); |
| 127 | 127 | 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); | |
| 129 | 130 | }); |
| 130 | 131 | |
| 131 | 132 | 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) => { | ||
| 93 | 93 | expect(html).not.toMatch(/<textarea[^>]*>(?:(?!<\/textarea>)[^])*<\/textarea>[^]*?<script>alert/); |
| 94 | 94 | }; |
| 95 | 95 | |
| 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"]) { | |
| 97 | 97 | test(`${path} escapes every typed word`, async () => { |
| 98 | 98 | const r = await call(path.replace(":id", String(id))); |
| 99 | 99 | expect(r.status).toBe(200); |
| @@ -107,6 +107,18 @@ for (const path of ["/", "/listings/:id", "/listings/:id/flyer", "/listings/:id/ | ||
| 107 | 107 | }); |
| 108 | 108 | } |
| 109 | 109 | |
| 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=""><img src=x onerror=alert(9)>`); | |
| 120 | }); | |
| 121 | ||
| 110 | 122 | test("share and Open Graph tags escape the listing's words", async () => { |
| 111 | 123 | for (const path of [`/listings/${id}`, `/listings/${id}/email`, `/listings/${id}/flyer`, `/listings/${id}/postcard`, `/homes/${id}`]) { |
| 112 | 124 | const html = await (await call(path)).text(); |
src/pages/layout.ts +4 −2
| @@ -42,6 +42,8 @@ export interface Frame { | ||
| 42 | 42 | readonly contact: string; |
| 43 | 43 | readonly palette: string; |
| 44 | 44 | 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; | |
| 45 | 47 | }; |
| 46 | 48 | } |
| 47 | 49 | |
| @@ -82,7 +84,7 @@ export function layout(body: string, who: Who, frame: Frame = {}): string { | ||
| 82 | 84 | </header>` |
| 83 | 85 | : `<header class="head public-head palette-${escape(a.palette)}"> |
| 84 | 86 | <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>`} | |
| 86 | 88 | </header>`; |
| 87 | 89 | const foot = |
| 88 | 90 | a === undefined |
| @@ -94,7 +96,7 @@ export function layout(body: string, who: Who, frame: Frame = {}): string { | ||
| 94 | 96 | : `<footer class="muted public-foot"> |
| 95 | 97 | <p>Equal Housing Opportunity</p> |
| 96 | 98 | <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>`} | |
| 98 | 100 | </footer>`; |
| 99 | 101 | return `<!doctype html> |
| 100 | 102 | <html lang="en"> |
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 | ${!editable ? "" : `<a class="button button-quiet" href="/listings/${l.id}/open-house">Sign-in screen for a tablet</a>`} | |
| 333 | 334 | <a class="button button-quiet" href="/listings/${l.id}/report">Seller report</a> |
| 334 | 335 | <a class="button button-quiet" href="/listings/${l.id}/schedule">Launch week</a> |
| 335 | 336 | <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 | ||
| 26 | 26 | <a href="/listings/${l.id}">← Back to the listing</a> |
| 27 | 27 | <button type="button" class="button" data-print hidden>Print</button> |
| 28 | 28 | <span class="muted">Letter size, one page.</span> |
| 29 | <a href="/listings/${l.id}/open-house">Sign-in screen for a tablet</a> | |
| 29 | 30 | </div> |
| 30 | 31 | <article class="signin palette-${escape(l.palette)}"> |
| 31 | 32 | <header class="signin-head"> |
src/routes.ts +33
| @@ -91,6 +91,7 @@ import { weekPage } from "./pages/week.ts"; | ||
| 91 | 91 | import { now } from "./clock.ts"; |
| 92 | 92 | import { dayOf } from "./showings.ts"; |
| 93 | 93 | import { postcardPage } from "./pages/postcard.ts"; |
| 94 | import { openHousePage, firstName } from "./pages/openhouse.ts"; | |
| 94 | 95 | import { signInPage } from "./pages/signin.ts"; |
| 95 | 96 | import { cardPage } from "./pages/card.ts"; |
| 96 | 97 | import { approval, asideOf, changedLine, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts"; |
| @@ -648,6 +649,38 @@ export const routes: readonly Route[] = [ | ||
| 648 | 649 | handle: (c) => |
| 649 | 650 | page(c.who, getListing(c.db, c.who, { id: Number(c.params.id) }), (listing) => cardPage({ who: c.who, listing })), |
| 650 | 651 | }, |
| 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" }) | |
| 681 | : page(c.who, found, () => ""); | |
| 682 | }, | |
| 683 | }, | |
| 651 | 684 | { |
| 652 | 685 | method: "GET", |
| 653 | 686 | 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 { | ||
| 1449 | 1449 | .public-foot { |
| 1450 | 1450 | text-align: center; |
| 1451 | 1451 | } |
| 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(, 1.125rem); | |
| 1460 | font-weight: 600; | |
| 1461 | } | |
| 1462 | .kiosk-form .kiosk-input { | |
| 1463 | min-height: calc(var() * 1.25); | |
| 1464 | font-size: 1.25rem; | |
| 1465 | } | |
| 1466 | .kiosk-go { | |
| 1467 | width: 100%; | |
| 1468 | justify-content: center; | |
| 1469 | min-height: calc(var() * 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]")) { | ||
| 190 | 190 | setTimeout(() => (button.textContent = "Copy subject"), 1500); |
| 191 | 191 | }); |
| 192 | 192 | } |
| 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 | } |
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.