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

opened this

Check · Review · Land · Live — Changes were asked on this head. Next: builder, changes were asked on this head.

A reviewer asked for changes Review

mason/listing-launch:mason/138-open-house-visitors-say-whether-they-alr → main 3 commits fork 3 threads · 3 unresolved

  • Waiting on review. sentinel asked for changes. Push a fix, and ask for another look.

    changes requested by sentinel

    Check passeddeploy 0c75115

    Website spec: 3 findings, 4 the edge owns (report only)
    • / no <link rel="canonical"> — canonical
    • / missing og:image — open-graph
    • / a 13800-byte body was sent without br or gzip though Accept-Encoding: br, gzip — https://specification.website/ (the edge owns this)
    • / http://127.0.0.1:36477/tokens.css?v=6bbacb4a: a 2873-byte body was sent without br or gzip though Accept-Encoding: br, gzip — https://specification.website/ (the edge owns this)
    • / http://127.0.0.1:36477/static/app.css?v=ef85484f: a 31340-byte body was sent without br or gzip though Accept-Encoding: br, gzip — https://specification.website/ (the edge owns this)
    • / no Cross-Origin-Opener-Policy — coop (the edge owns this)
    • / no ETag on the HTML — caching

Review

Sign in to review this change.
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:
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`.
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`.
1010 - `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
14701470 if (!found.ok) return found;
14711471 return ok(
14721472 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")
14741474 .all(found.value.id),
14751475 );
14761476 }
@@ -1478,17 +1478,17 @@ export function listVisitors(db: Database, who: Who, input: { id: number }): Res
14781478 export const addVisitor = (
14791479 db: Database,
14801480 who: Who,
1481 input: { id: number; name: unknown; email: unknown },
1481 input: { id: number; name: unknown; email: unknown; with_agent?: unknown },
14821482 ): Result<{ id: number }> => audited(db, who, "listing", "update", input.id, putVisitor(db, who, input));
14831483
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 }> {
14851485 const found = visitorsFor(db, who, input.id, "Sign in to add a visitor.");
14861486 if (!found.ok) return found;
14871487 const valid = validVisitor(input);
14881488 if (!valid.ok) return fail("invalid", valid.message);
14891489 const have = db.query<{ n: number }, [number]>("SELECT count(*) AS n FROM visitor WHERE listing_id = ?1").get(found.value.id)!.n;
14901490 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);

The tty calls addVisitor without with_agent and its list never shows the answer; extend src/tty.ts (add and list) and test it.

14921492 emit(db, "listing.changed", { id: found.value.id });
14931493 return ok({ id: found.value.id });
14941494 }
src/db.test.ts +17
@@ -1,3 +1,4 @@
1 import { Database } from "bun:sqlite";
12 import { expect, test } from "bun:test";
23 import { mkdtempSync } from "node:fs";
34 import { tmpdir } from "node:os";
@@ -21,3 +22,19 @@ test("migrations apply once, in order, and the file checks out", () => {
2122 ?.foreign_keys,
2223 ).toBe(1);
2324 });
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[] = [
258258 SELECT id, substr(updated_at, 1, 10), price, 'price' FROM listing WHERE was_price IS NOT NULL;
259259 INSERT INTO price_history (listing_id, date, price, kind)
260260 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'))`,
261263 ];
262264
263265 export function openDb(dir = dataDir): Database {
@@ -304,7 +306,8 @@ export function migrate(db: Database): number {
304306 db.exec(migrations[photoAlt]!);
305307 }
306308 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.

308311 db.query("INSERT INTO schema_version (version) VALUES (?1)").run(
309312 current + i + 1,
310313 );
src/followup.test.ts +3 −3
@@ -181,8 +181,8 @@ test("the visitors CSV: quoted per RFC 4180, formulas defused, author only", asy
181181 ],
182182 );
183183 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",""`);
186186 expect(lines[2]).toStartWith(`"'@evil, Jo","'[email protected]"`);
187187 expect(lines.length).toBe(4);
188188 });
@@ -216,7 +216,7 @@ test("the visitors CSV route gives the file to the author, the not-for-you answe
216216 expect(r.headers.get("content-type")).toBe("text/csv; charset=utf-8");
217217 expect(r.headers.get("content-disposition")).toBe('attachment; filename="412-alder-lane-visitors.csv"');
218218 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',
220220 );
221221 const page = await (await fetch(`${base}/listings/${id}/email?which=followup`, { headers: as("author") })).text();
222222 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 {
7171 const rows = visitors
7272 .map(
7373 (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>
7575 <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>
7676 </li>`,
7777 )
src/pages/openhouse.ts +5
@@ -37,6 +37,11 @@ export function openHousePage(input: {
3737 <label class="field">Your email
3838 <input class="input kiosk-input" name="email" type="email" inputmode="email" autocomplete="off" maxlength="120" required>
3939 </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>
4045 <button class="button kiosk-go brand-fill">Sign in</button>
4146 </form>
4247 </article>${toast(input.notice)}`,
src/routes.ts +1 −1
@@ -671,7 +671,7 @@ export const routes: readonly Route[] = [
671671 const form = await readForm(c.request);
672672 if (form === undefined) return text("too large", 413);
673673 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") ?? "" });
675675 if (done.ok) {
676676 const first = firstName(String(form.get("name") ?? "").replace(/\s+/g, " ").trim());
677677 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
118118 expect(rows.filter((r) => r.via === null)).toHaveLength(2);
119119 expect(approved()).toBe(before);
120120 });
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 {
226226 // The signed samples open finished: every asset signed off by its sample agent, never by a person's door.
227227 if (s.signed) signOff(db, made.id, `${s.listing.agent_name} (sample)`);
228228 }
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 }
229236 // A sample planted before the video script existed gets it, signed like the rest it was planted with.
230237 for (const id of draftMissingVideoScripts(db)) {
231238 const row = db
src/tablet.test.ts +47 −17
@@ -19,7 +19,7 @@ const as = (id: string | null): Record<string, string> => {
1919 const call = (id: string | null, path: string, init: RequestInit = {}) =>
2020 fetch(base + path, { redirect: "manual", ...init, headers: { ...as(id), ...(init.headers as object) } });
2121
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> => {
2323 const form = new FormData();
2424 for (const [k, v] of Object.entries({
2525 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
4242
4343 test("the agent gets the screen: brand, address, form, no tool header and no way out but Done", async () => {
4444 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`);
4646 expect(r.status).toBe(200);
4747 const html = await r.text();
4848 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
5454 const links = [...html.matchAll(/<a [^>]*href="([^"]+)"/g)].map((m) => m[1]).filter((h) => h !== "#main");
5555 expect(links).toEqual([`/listings/${id}`]);
5656 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`);
5959 });
6060
6161 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
6868
6969 test("a post adds a visitor the follow-up lists, and the thank-you shows no earlier visitor", async () => {
7070 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]");
7373 expect(r.status).toBe(303);
7474 expect(r.headers.get("location")).toBe(`/listings/${id}/open-house`);
7575 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();
7777 expect(html).toContain("Thanks for stopping by, Bo");
7878 expect(html).not.toContain("<b>Lee");
7979 expect(html).not.toContain("[email protected]");
8080 expect(html).not.toContain("Ann Early");
8181 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();
8383 expect(follow).toContain("[email protected]");
8484 expect(follow).toContain("[email protected]");
8585 });
8686
8787 test("a bad email shows in place and keeps the name; no open house still signs in", async () => {
8888 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");
9090 expect(r.status).toBe(422);
9191 const html = await r.text();
9292 expect(html).toContain("looks like [email protected]");
9393 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]");
9696 });
9797
9898 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`);
101101 const t = performance.now();
102 await call("pat", `/listings/${id}/open-house`);
102 await call("tablet-fay", `/listings/${id}/open-house`);
103103 expect(performance.now() - t).toBeLessThan(50);
104104 });
105105
106106 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");
108108 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`, {
110110 method: "POST",
111111 headers: { "content-type": "application/x-www-form-urlencoded", ...headers },
112112 body: new URLSearchParams({ name: "Mal", email: "[email protected]" }),
113113 });
114114 expect(r.status).toBe(403);
115115 }
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]");
117147 });
src/visitors.ts +16 −7
@@ -12,9 +12,15 @@ export interface Visitor {
1212 readonly id: number;
1313 readonly name: string;
1414 readonly email: string;
15 /** Whether they already work with an agent; blank when not asked or not answered. */
16 readonly with_agent: WithAgent;
1517 }
1618
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 };
1824
1925 const oneLine = (v: unknown): string =>
2026 String(v ?? "")
@@ -22,26 +28,29 @@ const oneLine = (v: unknown): string =>
2228 .replace(/\s+/g, " ")
2329 .trim();
2430
25 export function validVisitor(raw: { name?: unknown; email?: unknown }): Checked {
31 export function validVisitor(raw: { name?: unknown; email?: unknown; with_agent?: unknown }): Checked {
2632 const name = oneLine(raw.name);
2733 const email = oneLine(raw.email);
2834 if (name === "" || name.length > VISITOR_NAME_MAX)
2935 return { ok: false, message: `A visitor's name is 1 to ${VISITOR_NAME_MAX} characters.` };
3036 if (email.length > VISITOR_EMAIL_MAX || !/^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/.test(email))
3137 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 } };
3342 }
3443
3544 /** One RFC 4180 field, always quoted; a leading formula character is defused with `'`. */
3645 const field = (v: string): string => `"${(/^[=+\-@\t\r]/.test(v) ? `'${v}` : v).replace(/"/g, '""')}"`;
3746
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. */
3948 export function visitorsCsv(
4049 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 })[],
4251 ): string {
4352 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(","));
4655 return rows.join("\r\n") + "\r\n";
4756 }
static/app.css +12
@@ -1552,6 +1552,18 @@ main:focus {
15521552 min-height: calc(var(--size-tap) * 1.25);
15531553 font-size: 1.25rem;
15541554 }
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(--size-tap);
1565 margin-right: 1.5rem;
1566 }
15551567 .kiosk-go {
15561568 width: 100%;
15571569 justify-content: center;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs