Public: anyone can read this; the crew can change it.
Open house visitors say whether they already work with an agent, and the agent sees it on the visitor list and the CSV #142
Check · Review · Land · Live — Changes were asked on this head.
mason/listing-launch:mason/138-open-house-visitors-say-whether-they-alr → main 3 commits fork 3 threads · 3 unresolved
13 files
changed +129
−35
docs/design/areas/open-house.md +1 −1
| @@ -6,5 +6,5 @@ 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`. | |
| 9 | - `kiosk`, `kiosk-ask`, `kiosk-form`, `kiosk-input`, `kiosk-go`, `kiosk-error`, `kiosk-agent` (the optional Yes / No "working with an agent?" radios) — 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 | 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/actions.ts +4 −4
| @@ -1470,7 +1470,7 @@ export function listVisitors(db: Database, who: Who, input: { id: number }): Res | ||
| 1470 | 1470 | if (!found.ok) return found; |
| 1471 | 1471 | return ok( |
| 1472 | 1472 | db |
| 1473 | .query<Visitor, [number]>("SELECT id, name, email FROM visitor WHERE listing_id = ?1 ORDER BY id") | |
| 1473 | .query<Visitor, [number]>("SELECT id, name, email, coalesce(with_agent, '') AS with_agent FROM visitor WHERE listing_id = ?1 ORDER BY id") | |
| 1474 | 1474 | .all(found.value.id), |
| 1475 | 1475 | ); |
| 1476 | 1476 | } |
| @@ -1478,17 +1478,17 @@ export function listVisitors(db: Database, who: Who, input: { id: number }): Res | ||
| 1478 | 1478 | export const addVisitor = ( |
| 1479 | 1479 | db: Database, |
| 1480 | 1480 | who: Who, |
| 1481 | input: { id: number; name: unknown; email: unknown }, | |
| 1481 | input: { id: number; name: unknown; email: unknown; with_agent?: unknown }, | |
| 1482 | 1482 | ): Result<{ id: number }> => audited(db, who, "listing", "update", input.id, putVisitor(db, who, input)); |
| 1483 | 1483 | |
| 1484 | function putVisitor(db: Database, who: Who, input: { id: number; name: unknown; email: unknown }): Result<{ id: number }> { | |
| 1484 | function putVisitor(db: Database, who: Who, input: { id: number; name: unknown; email: unknown; with_agent?: 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 | 1487 | const valid = validVisitor(input); |
| 1488 | 1488 | if (!valid.ok) return fail("invalid", valid.message); |
| 1489 | 1489 | const have = db.query<{ n: number }, [number]>("SELECT count(*) AS n FROM visitor WHERE listing_id = ?1").get(found.value.id)!.n; |
| 1490 | 1490 | if (have >= VISITORS_MAX) return fail("invalid", `At most ${VISITORS_MAX} visitors on one sheet.`); |
| 1491 | db.query("INSERT INTO visitor (listing_id, name, email) VALUES (?1, ?2, ?3)").run(found.value.id, valid.value.name, valid.value.email); | |
| 1491 | db.query("INSERT INTO visitor (listing_id, name, email, with_agent) VALUES (?1, ?2, ?3, ?4)").run(found.value.id, valid.value.name, valid.value.email, valid.value.with_agent === "" ? null : valid.value.with_agent); | |
| 1492 | 1492 | emit(db, "listing.changed", { id: found.value.id }); |
| 1493 | 1493 | return ok({ id: found.value.id }); |
| 1494 | 1494 | } |
src/db.test.ts +17
| @@ -1,3 +1,4 @@ | ||
| 1 | import { Database } from "bun:sqlite"; | |
| 1 | 2 | import { expect, test } from "bun:test"; |
| 2 | 3 | import { mkdtempSync } from "node:fs"; |
| 3 | 4 | import { tmpdir } from "node:os"; |
| @@ -21,3 +22,19 @@ test("migrations apply once, in order, and the file checks out", () => { | ||
| 21 | 22 | ?.foreign_keys, |
| 22 | 23 | ).toBe(1); |
| 23 | 24 | }); |
| 25 | ||
| 26 | test("the with_agent column is added in place: an old visitor survives and reads blank", () => { | |
| 27 | const db = new Database(":memory:"); | |
| 28 | const last = migrations.length - 1; | |
| 29 | const old = migrations.slice(0, last); | |
| 30 | db.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))"); | |
| 31 | old.forEach((sql, i) => { | |
| 32 | db.exec(sql); | |
| 33 | db.query("INSERT INTO schema_version (version) VALUES (?1)").run(i + 1); | |
| 34 | }); | |
| 35 | db.exec("INSERT INTO visitor (listing_id, name, email) VALUES (1, 'Old Visitor', '[email protected]')"); | |
| 36 | migrate(db); | |
| 37 | const row = db.query<{ name: string; with_agent: string | null }, []>("SELECT name, with_agent FROM visitor").all(); | |
| 38 | expect(row).toEqual([{ name: "Old Visitor", with_agent: null }]); | |
| 39 | expect(() => db.exec("INSERT INTO visitor (listing_id, name, email, with_agent) VALUES (1, 'x', '[email protected]', 'maybe')")).toThrow(); | |
| 40 | }); |
src/db.ts +4 −1
| @@ -258,6 +258,8 @@ export const migrations: string[] = [ | ||
| 258 | 258 | SELECT id, substr(updated_at, 1, 10), price, 'price' FROM listing WHERE was_price IS NOT NULL; |
| 259 | 259 | INSERT INTO price_history (listing_id, date, price, kind) |
| 260 | 260 | SELECT id, substr(updated_at, 1, 10), sold_price, 'sold' FROM listing WHERE status = 'sold' AND sold_price IS NOT NULL`, |
| 261 | // Whether a visitor already works with an agent: added in place, old visitors keep NULL and read as blank. | |
| 262 | `ALTER TABLE visitor ADD COLUMN with_agent TEXT CHECK (with_agent IN ('yes', 'no'))`, | |
| 261 | 263 | ]; |
| 262 | 264 | |
| 263 | 265 | export function openDb(dir = dataDir): Database { |
| @@ -304,7 +306,8 @@ export function migrate(db: Database): number { | ||
| 304 | 306 | db.exec(migrations[photoAlt]!); |
| 305 | 307 | } |
| 306 | 308 | migrations.slice(current).forEach((sql, i) => { |
| 307 | db.exec(sql); | |
| 309 | // An ALTER cannot be run twice: a column already there is the step already done. | |
| 310 | if (!(sql.includes("ADD COLUMN with_agent") && hasColumn(db, "visitor", "with_agent"))) db.exec(sql); | |
Don't sniff SQL text in the generic loop; guard this migration by index like photoAlt above. |
||
| 308 | 311 | db.query("INSERT INTO schema_version (version) VALUES (?1)").run( |
| 309 | 312 | current + i + 1, |
| 310 | 313 | ); |
src/followup.test.ts +3 −3
| @@ -181,8 +181,8 @@ test("the visitors CSV: quoted per RFC 4180, formulas defused, author only", asy | ||
| 181 | 181 | ], |
| 182 | 182 | ); |
| 183 | 183 | const lines = csv.split("\r\n"); |
| 184 | expect(lines[0]).toBe('"Name","Email","Open house","Listing"'); | |
| 185 | expect(lines[1]).toBe(`"'=HYPERLINK(""http://x"",""a"")","[email protected]","2027-03-07 13:00-15:00","1 ""Main"" St"`); | |
| 184 | expect(lines[0]).toBe('"Name","Email","Open house","Listing","Working with an agent"'); | |
| 185 | expect(lines[1]).toBe(`"'=HYPERLINK(""http://x"",""a"")","[email protected]","2027-03-07 13:00-15:00","1 ""Main"" St",""`); | |
| 186 | 186 | expect(lines[2]).toStartWith(`"'@evil, Jo","'[email protected]"`); |
| 187 | 187 | expect(lines.length).toBe(4); |
| 188 | 188 | }); |
| @@ -216,7 +216,7 @@ test("the visitors CSV route gives the file to the author, the not-for-you answe | ||
| 216 | 216 | expect(r.headers.get("content-type")).toBe("text/csv; charset=utf-8"); |
| 217 | 217 | expect(r.headers.get("content-disposition")).toBe('attachment; filename="412-alder-lane-visitors.csv"'); |
| 218 | 218 | expect(await r.text()).toBe( |
| 219 | '"Name","Email","Open house","Listing"\r\n"\'=cmd|""x""","[email protected]","2027-03-07 13:00-15:00","412 Alder Lane"\r\n', | |
| 219 | '"Name","Email","Open house","Listing","Working with an agent"\r\n"\'=cmd|""x""","[email protected]","2027-03-07 13:00-15:00","412 Alder Lane",""\r\n', | |
| 220 | 220 | ); |
| 221 | 221 | const page = await (await fetch(`${base}/listings/${id}/email?which=followup`, { headers: as("author") })).text(); |
| 222 | 222 | expect(page).toContain(`href="/listings/${id}/visitors.csv"`); |
src/pages/email.ts +1 −1
| @@ -71,7 +71,7 @@ function visitorList(l: Launched, visitors: readonly Visitor[]): string { | ||
| 71 | 71 | const rows = visitors |
| 72 | 72 | .map( |
| 73 | 73 | (v) => `<li class="row visitor"> |
| 74 | <span><b>${escape(v.name)}</b> <span class="muted">${escape(v.email)}</span></span> | |
| 74 | <span><b>${escape(v.name)}</b> <span class="muted">${escape(v.email)}</span>${v.with_agent === "" ? "" : ` <span class="muted">· ${v.with_agent === "yes" ? "Has an agent" : "No agent"}</span>`}</span> | |
| 75 | 75 | <form method="post" action="/listings/${l.id}/visitors/${v.id}/delete"><button class="button button-quiet" aria-label="Remove ${escape(v.name)} from the list">Remove</button></form> |
| 76 | 76 | </li>`, |
| 77 | 77 | ) |
src/pages/openhouse.ts +5
| @@ -37,6 +37,11 @@ export function openHousePage(input: { | ||
| 37 | 37 | <label class="field">Your email |
| 38 | 38 | <input class="input kiosk-input" name="email" type="email" inputmode="email" autocomplete="off" maxlength="120" required> |
| 39 | 39 | </label> |
| 40 | <fieldset class="field kiosk-agent"> | |
| 41 | <legend>Are you working with an agent? <span class="muted">(optional)</span></legend> | |
| 42 | <label><input type="radio" name="with_agent" value="yes"> Yes</label> | |
| 43 | <label><input type="radio" name="with_agent" value="no"> No</label> | |
| 44 | </fieldset> | |
| 40 | 45 | <button class="button kiosk-go brand-fill">Sign in</button> |
| 41 | 46 | </form> |
| 42 | 47 | </article>${toast(input.notice)}`, |
src/routes.ts +1 −1
| @@ -671,7 +671,7 @@ export const routes: readonly Route[] = [ | ||
| 671 | 671 | const form = await readForm(c.request); |
| 672 | 672 | if (form === undefined) return text("too large", 413); |
| 673 | 673 | const id = Number(c.params.id); |
| 674 | const done = addVisitor(c.db, c.who, { id, name: form.get("name"), email: form.get("email") }); | |
| 674 | const done = addVisitor(c.db, c.who, { id, name: form.get("name"), email: form.get("email"), with_agent: form.get("with_agent") ?? "" }); | |
| 675 | 675 | if (done.ok) { |
| 676 | 676 | const first = firstName(String(form.get("name") ?? "").replace(/\s+/g, " ").trim()); |
| 677 | 677 | return back(`Thanks for stopping by, ${first}`, `/listings/${id}/open-house`); |
src/sample.test.ts +11
| @@ -118,3 +118,14 @@ test("listings that predate the video script get it on boot, and keep every othe | ||
| 118 | 118 | expect(rows.filter((r) => r.via === null)).toHaveLength(2); |
| 119 | 119 | expect(approved()).toBe(before); |
| 120 | 120 | }); |
| 121 | ||
| 122 | test("Alder Lane's sample visitors answer both yes and no", () => { | |
| 123 | const db = fresh(); | |
| 124 | seed(db); | |
| 125 | const answers = db | |
| 126 | .query<{ a: string }, [number]>("SELECT coalesce(with_agent, '') AS a FROM visitor WHERE listing_id = ?1") | |
| 127 | .all(idOf(db, "412 Alder%")) | |
| 128 | .map((r) => r.a); | |
| 129 | expect(answers).toContain("yes"); | |
| 130 | expect(answers).toContain("no"); | |
| 131 | }); |
src/sample.ts +7
| @@ -226,6 +226,13 @@ export function seed(db: Database): void { | ||
| 226 | 226 | // The signed samples open finished: every asset signed off by its sample agent, never by a person's door. |
| 227 | 227 | if (s.signed) signOff(db, made.id, `${s.listing.agent_name} (sample)`); |
| 228 | 228 | } |
| 229 | // The first sample's sign-in sheet, planted once: one visitor with an agent, one without, one who left it blank. | |
| 230 | if (db.query("INSERT OR IGNORE INTO seeded (name) VALUES ('sample-visitors')").run().changes > 0) { | |
| 231 | const first = db.query<{ id: number }, []>("SELECT id FROM listing WHERE author_id = 'sample' AND address = '" + SAMPLE.address.replace(/'/g, "''") + "' ORDER BY id LIMIT 1").get(); | |
| 232 | if (first !== null) | |
SQL built by string concatenation; bind the address as a parameter. |
||
| 233 | for (const [name, email, answer] of [["Dana Whitfield", "[email protected]", "no"], ["Sam Okafor", "[email protected]", "yes"], ["Lee Park", "[email protected]", null]] as const) | |
| 234 | db.query("INSERT INTO visitor (listing_id, name, email, with_agent) VALUES (?1, ?2, ?3, ?4)").run(first.id, name, email, answer); | |
| 235 | } | |
| 229 | 236 | // A sample planted before the video script existed gets it, signed like the rest it was planted with. |
| 230 | 237 | for (const id of draftMissingVideoScripts(db)) { |
| 231 | 238 | const row = db |
src/tablet.test.ts +47 −17
| @@ -19,7 +19,7 @@ const as = (id: string | null): Record<string, string> => { | ||
| 19 | 19 | const call = (id: string | null, path: string, init: RequestInit = {}) => |
| 20 | 20 | fetch(base + path, { redirect: "manual", ...init, headers: { ...as(id), ...(init.headers as object) } }); |
| 21 | 21 | |
| 22 | const launch = async (extra: Record<string, string>, who = "pat"): Promise<string> => { | |
| 22 | const launch = async (extra: Record<string, string>, who = "tablet-pat"): Promise<string> => { | |
| 23 | 23 | const form = new FormData(); |
| 24 | 24 | for (const [k, v] of Object.entries({ |
| 25 | 25 | address: "9 Tablet St, Portland, OR 97201", beds: 3, baths: 2, sqft: 1800, year: 1999, price: 500000, |
| @@ -42,7 +42,7 @@ const OPEN = { open1_date: "2027-03-07", open1_start: "13:00", open1_end: "15:00 | ||
| 42 | 42 | |
| 43 | 43 | test("the agent gets the screen: brand, address, form, no tool header and no way out but Done", async () => { |
| 44 | 44 | const id = await launch(OPEN); |
| 45 | const r = await call("pat", `/listings/${id}/open-house`); | |
| 45 | const r = await call("tablet-pat", `/listings/${id}/open-house`); | |
| 46 | 46 | expect(r.status).toBe(200); |
| 47 | 47 | const html = await r.text(); |
| 48 | 48 | expect(html).toContain("9 Tablet St"); |
| @@ -54,8 +54,8 @@ test("the agent gets the screen: brand, address, form, no tool header and no way | ||
| 54 | 54 | const links = [...html.matchAll(/<a [^>]*href="([^"]+)"/g)].map((m) => m[1]).filter((h) => h !== "#main"); |
| 55 | 55 | expect(links).toEqual([`/listings/${id}`]); |
| 56 | 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`); | |
| 57 | expect(await (await call("tablet-pat", `/listings/${id}`)).text()).toContain("Sign-in screen for a tablet"); | |
| 58 | expect(await (await call("tablet-pat", `/listings/${id}/sign-in`)).text()).toContain(`/listings/${id}/open-house`); | |
| 59 | 59 | }); |
| 60 | 60 | |
| 61 | 61 | test("a guest and another user are refused, on the page and on the post", async () => { |
| @@ -68,50 +68,80 @@ test("a guest and another user are refused, on the page and on the post", async | ||
| 68 | 68 | |
| 69 | 69 | test("a post adds a visitor the follow-up lists, and the thank-you shows no earlier visitor", async () => { |
| 70 | 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]"); | |
| 71 | expect((await post(id, "tablet-pat", "Ann Early", "[email protected]")).status).toBe(303); | |
| 72 | const r = await post(id, "tablet-pat", "Bo <b>Lee</b>", "[email protected]"); | |
| 73 | 73 | expect(r.status).toBe(303); |
| 74 | 74 | expect(r.headers.get("location")).toBe(`/listings/${id}/open-house`); |
| 75 | 75 | const cookie = r.headers.get("set-cookie")!.split(";")[0]; |
| 76 | const html = await (await call("pat", `/listings/${id}/open-house`, { headers: { cookie } })).text(); | |
| 76 | const html = await (await call("tablet-pat", `/listings/${id}/open-house`, { headers: { cookie } })).text(); | |
| 77 | 77 | expect(html).toContain("Thanks for stopping by, Bo"); |
| 78 | 78 | expect(html).not.toContain("<b>Lee"); |
| 79 | 79 | expect(html).not.toContain("[email protected]"); |
| 80 | 80 | expect(html).not.toContain("Ann Early"); |
| 81 | 81 | expect(html).not.toContain("[email protected]"); |
| 82 | const follow = await (await call("pat", `/listings/${id}/email?which=followup`)).text(); | |
| 82 | const follow = await (await call("tablet-pat", `/listings/${id}/email?which=followup`)).text(); | |
| 83 | 83 | expect(follow).toContain("[email protected]"); |
| 84 | 84 | expect(follow).toContain("[email protected]"); |
| 85 | 85 | }); |
| 86 | 86 | |
| 87 | 87 | test("a bad email shows in place and keeps the name; no open house still signs in", async () => { |
| 88 | 88 | const id = await launch({}); |
| 89 | const r = await post(id, "pat", "Cy Quote", "not-an-email"); | |
| 89 | const r = await post(id, "tablet-pat", "Cy Quote", "not-an-email"); | |
| 90 | 90 | expect(r.status).toBe(422); |
| 91 | 91 | const html = await r.text(); |
| 92 | 92 | expect(html).toContain("looks like [email protected]"); |
| 93 | 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]"); | |
| 94 | expect((await post(id, "tablet-pat", "Cy Quote", "[email protected]")).status).toBe(303); | |
| 95 | expect(await (await call("tablet-pat", `/listings/${id}/visitors.csv`)).text()).toContain("[email protected]"); | |
| 96 | 96 | }); |
| 97 | 97 | |
| 98 | 98 | test("the screen answers fast", async () => { |
| 99 | const id = await launch(OPEN); | |
| 100 | await call("pat", `/listings/${id}/open-house`); | |
| 99 | const id = await launch(OPEN, "tablet-fay"); | |
| 100 | await call("tablet-fay", `/listings/${id}/open-house`); | |
| 101 | 101 | const t = performance.now(); |
| 102 | await call("pat", `/listings/${id}/open-house`); | |
| 102 | await call("tablet-fay", `/listings/${id}/open-house`); | |
| 103 | 103 | expect(performance.now() - t).toBeLessThan(50); |
| 104 | 104 | }); |
| 105 | 105 | |
| 106 | 106 | test("a cross-site POST is refused and adds no visitor", async () => { |
| 107 | const id = await launch(OPEN, "quinn"); | |
| 107 | const id = await launch(OPEN, "tablet-quinn"); | |
| 108 | 108 | for (const headers of [{ origin: "https://evil.example" }, { "sec-fetch-site": "cross-site" }]) { |
| 109 | const r = await call("quinn", `/listings/${id}/open-house`, { | |
| 109 | const r = await call("tablet-quinn", `/listings/${id}/open-house`, { | |
| 110 | 110 | method: "POST", |
| 111 | 111 | headers: { "content-type": "application/x-www-form-urlencoded", ...headers }, |
| 112 | 112 | body: new URLSearchParams({ name: "Mal", email: "[email protected]" }), |
| 113 | 113 | }); |
| 114 | 114 | expect(r.status).toBe(403); |
| 115 | 115 | } |
| 116 | expect(await (await call("quinn", `/listings/${id}/visitors.csv`)).text()).not.toContain("[email protected]"); | |
| 116 | expect(await (await call("tablet-quinn", `/listings/${id}/visitors.csv`)).text()).not.toContain("[email protected]"); | |
| 117 | }); | |
| 118 | ||
| 119 | test("the tablet asks whether the visitor works with an agent: stored, shown on the list and in the CSV, anything else refused", async () => { | |
| 120 | const id = await launch(OPEN, "tablet-ava"); | |
| 121 | const page = await (await call("tablet-ava", `/listings/${id}/open-house`)).text(); | |
| 122 | expect(page).toContain("Are you working with an agent?"); | |
| 123 | expect(page).toContain('name="with_agent" value="yes"'); | |
| 124 | const send = (name: string, extra: Record<string, string>) => | |
| 125 | call("tablet-ava", `/listings/${id}/open-house`, { | |
| 126 | method: "POST", | |
| 127 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 128 | body: new URLSearchParams({ name, email: `${name.toLowerCase()}@example.com`, ...extra }), | |
| 129 | }); | |
| 130 | expect((await send("Yes", { with_agent: "yes" })).status).toBe(303); | |
| 131 | expect((await send("Nope", { with_agent: "no" })).status).toBe(303); | |
| 132 | expect((await send("Blank", {})).status).toBe(303); | |
| 133 | const bad = await send("Bad", { with_agent: "maybe" }); | |
| 134 | expect(bad.status).toBe(422); | |
| 135 | expect(await bad.text()).toContain("Yes or No"); | |
| 136 | const csv = (await (await call("tablet-ava", `/listings/${id}/visitors.csv`)).text()).split("\r\n"); | |
| 137 | expect(csv[0]).toEndWith('"Working with an agent"'); | |
| 138 | expect(csv[1]).toEndWith('"Yes"'); | |
| 139 | expect(csv[2]).toEndWith('"No"'); | |
| 140 | expect(csv[3]).toEndWith('""'); | |
| 141 | expect(csv.join("")).not.toContain("[email protected]"); | |
| 142 | const list = await (await call("tablet-ava", `/listings/${id}/email?which=followup`)).text(); | |
| 143 | expect(list).toContain("Has an agent"); | |
| 144 | expect(list).toContain("No agent"); | |
| 145 | const thanks = await (await call("tablet-ava", `/listings/${id}/open-house`)).text(); | |
| 146 | expect(thanks).not.toContain("[email protected]"); | |
| 117 | 147 | }); |
src/visitors.ts +16 −7
| @@ -12,9 +12,15 @@ export interface Visitor { | ||
| 12 | 12 | readonly id: number; |
| 13 | 13 | readonly name: string; |
| 14 | 14 | readonly email: string; |
| 15 | /** Whether they already work with an agent; blank when not asked or not answered. */ | |
| 16 | readonly with_agent: WithAgent; | |
| 15 | 17 | } |
| 16 | 18 | |
| 17 | type Checked = { ok: true; value: { name: string; email: string } } | { ok: false; message: string }; | |
| 19 | export type WithAgent = "yes" | "no" | ""; | |
| 20 | ||
| 21 | export const WITH_AGENT_LABEL: Record<WithAgent, string> = { yes: "Yes", no: "No", "": "" }; | |
| 22 | ||
| 23 | type Checked = { ok: true; value: { name: string; email: string; with_agent: WithAgent } } | { ok: false; message: string }; | |
| 18 | 24 | |
| 19 | 25 | const oneLine = (v: unknown): string => |
| 20 | 26 | String(v ?? "") |
| @@ -22,26 +28,29 @@ const oneLine = (v: unknown): string => | ||
| 22 | 28 | .replace(/\s+/g, " ") |
| 23 | 29 | .trim(); |
| 24 | 30 | |
| 25 | export function validVisitor(raw: { name?: unknown; email?: unknown }): Checked { | |
| 31 | export function validVisitor(raw: { name?: unknown; email?: unknown; with_agent?: unknown }): Checked { | |
| 26 | 32 | const name = oneLine(raw.name); |
| 27 | 33 | const email = oneLine(raw.email); |
| 28 | 34 | if (name === "" || name.length > VISITOR_NAME_MAX) |
| 29 | 35 | return { ok: false, message: `A visitor's name is 1 to ${VISITOR_NAME_MAX} characters.` }; |
| 30 | 36 | if (email.length > VISITOR_EMAIL_MAX || !/^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/.test(email)) |
| 31 | 37 | return { ok: false, message: "A visitor's email looks like [email protected]." }; |
| 32 | return { ok: true, value: { name, email } }; | |
| 38 | const answer = raw.with_agent === undefined || raw.with_agent === null ? "" : raw.with_agent; | |
| 39 | if (answer !== "" && answer !== "yes" && answer !== "no") | |
| 40 | return { ok: false, message: "Working with an agent is Yes or No." }; | |
| 41 | return { ok: true, value: { name, email, with_agent: answer } }; | |
| 33 | 42 | } |
| 34 | 43 | |
| 35 | 44 | /** One RFC 4180 field, always quoted; a leading formula character is defused with `'`. */ |
| 36 | 45 | const field = (v: string): string => `"${(/^[=+\-@\t\r]/.test(v) ? `'${v}` : v).replace(/"/g, '""')}"`; |
| 37 | 46 | |
| 38 | /** The visitors as a CSV a CRM imports: `Name,Email,Open house,Listing`, CRLF lines, one row per visitor. */ | |
| 47 | /** The visitors as a CSV a CRM imports: `Name,Email,Open house,Listing,Working with an agent`, CRLF lines, one row per visitor. */ | |
| 39 | 48 | export function visitorsCsv( |
| 40 | 49 | listing: { readonly address: string; readonly open_houses: readonly { date: string; start: string; end: string }[] }, |
| 41 | visitors: readonly Pick<Visitor, "name" | "email">[], | |
| 50 | visitors: readonly (Pick<Visitor, "name" | "email"> & { readonly with_agent?: WithAgent })[], | |
| 42 | 51 | ): string { |
| 43 | 52 | const open = listing.open_houses.map((o) => `${o.date} ${o.start}-${o.end}`).join("; "); |
| 44 | const rows = [["Name", "Email", "Open house", "Listing"].map(field).join(",")]; | |
| 45 | for (const v of visitors) rows.push([v.name, v.email, open, listing.address].map(field).join(",")); | |
| 53 | const rows = [["Name", "Email", "Open house", "Listing", "Working with an agent"].map(field).join(",")]; | |
| 54 | for (const v of visitors) rows.push([v.name, v.email, open, listing.address, WITH_AGENT_LABEL[v.with_agent ?? ""]].map(field).join(",")); | |
| 46 | 55 | return rows.join("\r\n") + "\r\n"; |
| 47 | 56 | } |
static/app.css +12
| @@ -1552,6 +1552,18 @@ main:focus { | ||
| 1552 | 1552 | min-height: calc(var() * 1.25); |
| 1553 | 1553 | font-size: 1.25rem; |
| 1554 | 1554 | } |
| 1555 | .kiosk-agent { | |
| 1556 | border: 0; | |
| 1557 | padding: 0; | |
| 1558 | font-size: 1.25rem; | |
| 1559 | } | |
| 1560 | .kiosk-agent label { | |
| 1561 | display: inline-flex; | |
| 1562 | align-items: center; | |
| 1563 | gap: 0.5rem; | |
| 1564 | min-height: var(); | |
| 1565 | margin-right: 1.5rem; | |
| 1566 | } | |
| 1555 | 1567 | .kiosk-go { |
| 1556 | 1568 | width: 100%; |
| 1557 | 1569 | justify-content: center; |
The tty calls addVisitor without with_agent and its list never shows the answer; extend src/tty.ts (add and list) and test it.