Public: anyone can read this; the crew can change it.
Type your details once: a new listing's form starts with the agent's name, brokerage, license, phone, email, colours and headshot from their last listing #71
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/62-type-your-details-once-a-new-listing-s-f → main 2 commits fork 1 thread · 1 unresolved
7 files
changed +144
−16
README.md +3 −1
| @@ -132,7 +132,9 @@ list cannot drift from the app. | ||
| 132 | 132 | the same filter and search as the home list; an unknown status is All and |
| 133 | 133 | `q` is capped at 100 characters. |
| 134 | 134 | Your brand (name, brokerage, license number, phone, email, headshot, |
| 135 | colour) is asked once and carried to your next listing. | |
| 135 | colour) is asked once: the new-listing form starts filled in from your most | |
| 136 | recent listing (only ever your own), every field editable, and your headshot | |
| 137 | is reused unless you choose a new file (the form says which listing it is from). | |
| 136 | 138 | 2. **Pick a tone** — *classic* (measured), *warm* (inviting) or *bold* |
| 137 | 139 | (short and punchy) — and press **Launch**. |
| 138 | 140 | 3. **Read every word.** The listing opens at its own address |
src/actions.ts +2 −8
| @@ -44,6 +44,7 @@ import { | ||
| 44 | 44 | unwrite as unwritePhotos, |
| 45 | 45 | write as writePhoto, |
| 46 | 46 | } from "./photos.ts"; |
| 47 | import { headshotFrom } from "./agent.ts"; | |
| 47 | 48 | import type { Who } from "./who.ts"; |
| 48 | 49 | |
| 49 | 50 | export interface Note { |
| @@ -638,14 +639,7 @@ function launch( | ||
| 638 | 639 | ? { name: photoName(face.kind), bytes: input.headshot!.bytes } |
| 639 | 640 | : undefined; |
| 640 | 641 | // No new headshot: the agent's last one carries over. |
| 641 | const headshot = | |
| 642 | newFace?.name ?? | |
| 643 | db | |
| 644 | .query<{ headshot: string }, [string]>( | |
| 645 | "SELECT headshot FROM listing WHERE author_id = ?1 AND headshot IS NOT NULL ORDER BY id DESC LIMIT 1", | |
| 646 | ) | |
| 647 | .get(who.id)?.headshot ?? | |
| 648 | null; | |
| 642 | const headshot = newFace?.name ?? headshotFrom(db, who)?.name ?? null; | |
| 649 | 643 | const writing = newFace === undefined ? files : [...files, newFace]; |
| 650 | 644 | let listing: Launched; |
| 651 | 645 | try { |
src/agent.ts added +41
| @@ -0,0 +1,41 @@ | ||
| 1 | /** | |
| 2 | * What the app remembers of the agent: their brand and headshot from the | |
| 3 | * listing they launched last. Only listings the person authored are read, so | |
| 4 | * nobody ever sees another person's details. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | import { fromRow, type Listing, type ListingRow } from "./listing.ts"; | |
| 9 | import { pathOf } from "./photos.ts"; | |
| 10 | import type { Who } from "./who.ts"; | |
| 11 | ||
| 12 | /** The signed-in person's most recent listing, or nothing (anon, or none yet). */ | |
| 13 | export function lastListing(db: Database, who: Who): Listing | undefined { | |
| 14 | if (who.role === "anon") return undefined; | |
| 15 | const row = db | |
| 16 | .query<ListingRow, [string]>( | |
| 17 | "SELECT * FROM listing WHERE author_id = ?1 ORDER BY id DESC LIMIT 1", | |
| 18 | ) | |
| 19 | .get(who.id); | |
| 20 | return row === null ? undefined : fromRow(row); | |
| 21 | } | |
| 22 | ||
| 23 | /** | |
| 24 | * The headshot a launch without a new one reuses, and the address of the | |
| 25 | * listing it came from: the author's newest headshot whose file is still | |
| 26 | * there. The form's note and the launch both read this, so they name one file. | |
| 27 | */ | |
| 28 | export function headshotFrom( | |
| 29 | db: Database, | |
| 30 | who: Who, | |
| 31 | ): { name: string; address: string } | undefined { | |
| 32 | if (who.role === "anon") return undefined; | |
| 33 | const row = db | |
| 34 | .query<{ address: string; headshot: string }, [string]>( | |
| 35 | "SELECT address, headshot FROM listing WHERE author_id = ?1 AND headshot IS NOT NULL ORDER BY id DESC LIMIT 1", | |
| 36 | ) | |
| 37 | .get(who.id); | |
| 38 | return row === null || pathOf(db, row.headshot) === undefined | |
| 39 | ? undefined | |
| 40 | : { name: row.headshot, address: row.address }; | |
| 41 | } |
src/hostile.test.ts +2
| @@ -102,6 +102,8 @@ for (const path of ["/", "/listings/:id", "/listings/:id/flyer", "/listings/:id/ | ||
| 102 | 102 | expectInert(html); |
| 103 | 103 | // The words are still there, as typed, escaped. |
| 104 | 104 | if (path !== "/") expect(html).toContain(`"><img src=x onerror=alert(2)>`); |
| 105 | // On the home page the hostile brokerage is prefilled into the form, escaped. | |
| 106 | if (path === "/") expect(html).toContain(`name="brokerage" value="'><iframe src="javascript:alert(8)"></iframe> 🏠"`); | |
| 105 | 107 | }); |
| 106 | 108 | } |
| 107 | 109 |
src/pages/home.ts +10 −3
| @@ -67,6 +67,8 @@ export function home(input: { | ||
| 67 | 67 | listings: readonly ListingSummary[]; |
| 68 | 68 | find?: Find; |
| 69 | 69 | draft: Draft; |
| 70 | /** The listing the agent's saved headshot comes from, when they have one. */ | |
| 71 | headshotFrom?: { address: string } | undefined; | |
| 70 | 72 | notice?: string | undefined; |
| 71 | 73 | }): string { |
| 72 | 74 | const { who, listings, draft, notice } = input; |
| @@ -75,7 +77,7 @@ export function home(input: { | ||
| 75 | 77 | `<section class="compose" aria-labelledby="compose-title"> |
| 76 | 78 | <h2 id="compose-title">Launch a listing</h2> |
| 77 | 79 | <p class="muted lede">The facts and photos in; the MLS description, three social posts, a flyer and an email blast out — in your brand, each checked for Fair Housing language. You approve every word.</p> |
| 78 | ${form(who, draft)} | |
| 80 | ${form(who, draft, undefined, input.headshotFrom)} | |
| 79 | 81 | </section> |
| 80 | 82 | <section class="launched" aria-labelledby="launched-title"> |
| 81 | 83 | <h2 id="launched-title">Launched</h2> |
| @@ -107,7 +109,12 @@ export interface Editing { | ||
| 107 | 109 | readonly id: number; |
| 108 | 110 | } |
| 109 | 111 | |
| 110 | export function form(who: Who, draft: Draft, editing?: Editing): string { | |
| 112 | export function form( | |
| 113 | who: Who, | |
| 114 | draft: Draft, | |
| 115 | editing?: Editing, | |
| 116 | headshotFrom?: { address: string }, | |
| 117 | ): string { | |
| 111 | 118 | const tone = draft.tone ?? "classic"; |
| 112 | 119 | const palette = draft.palette ?? "navy"; |
| 113 | 120 | const launch = |
| @@ -120,7 +127,7 @@ export function form(who: Who, draft: Draft, editing?: Editing): string { | ||
| 120 | 127 | : `<button class="button button-launch">Launch</button>`; |
| 121 | 128 | const photos = `<label class="field"><span>Photos <small class="muted">up to ${PHOTOS_MAX}, JPEG, PNG or WebP, 24 MB in all; the first is the hero</small></span> |
| 122 | 129 | <input class="input file" type="file" name="photos" multiple accept="image/jpeg,image/png,image/webp"></label>`; |
| 123 | const headshot = `<label class="field"><span>Headshot <small class="muted">optional; your last one carries over</small></span> | |
| 130 | const headshot = `<label class="field"><span>Headshot <small class="muted">${headshotFrom === undefined ? "optional; your last one carries over" : `Using your headshot from ${escape(headshotFrom.address)} — choose a file to replace it`}</small></span> | |
| 124 | 131 | <input class="input file" type="file" name="headshot" accept="image/jpeg,image/png,image/webp"></label>`; |
| 125 | 132 | return `<form class="card form" method="post" ${editing === undefined ? 'action="/listings" enctype="multipart/form-data"' : `action="/listings/${editing.id}/facts"`}> |
| 126 | 133 | <fieldset> |
src/prefill.test.ts added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | import { afterAll, expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync, readdirSync, rmSync } from "node:fs"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { SAMPLE } from "./sample.ts"; | |
| 6 | import { serve } from "./server.ts"; | |
| 7 | import { sign } from "./who.ts"; | |
| 8 | ||
| 9 | const KEY = "a-key-only-the-forge-holds"; | |
| 10 | const dataDir = mkdtempSync(join(tmpdir(), "prefill-")); | |
| 11 | const server = serve({ port: 0, dataDir, identityKey: KEY }); | |
| 12 | const base = `http://${server.hostname}:${server.port}`; | |
| 13 | afterAll(() => server.stop(true)); | |
| 14 | ||
| 15 | const as = (id: string) => { | |
| 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 PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]); | |
| 20 | ||
| 21 | const launch = async (id: string, brokerage: string, headshot?: boolean): Promise<number> => { | |
| 22 | const form = new FormData(); | |
| 23 | for (const [k, v] of Object.entries({ | |
| 24 | address: "412 Alder Lane", beds: 3, baths: 2, sqft: 1800, lot: "0.2 acre", year: 1999, | |
| 25 | price: 500000, tone: "classic", palette: "forest", agent_name: `Agent ${id}`, brokerage, | |
| 26 | license: "L-123456", agent_phone: "+1 (503) 555-0100", agent_email: `${id}@example.com`, | |
| 27 | features: SAMPLE.features.slice(0, 5).join("\n"), | |
| 28 | })) | |
| 29 | form.set(k, String(v)); | |
| 30 | if (headshot) form.set("headshot", new File([PNG], "me.png", { type: "image/png" })); | |
| 31 | const r = await fetch(base + "/listings", { method: "POST", body: form, redirect: "manual", headers: as(id) }); | |
| 32 | expect(r.status).toBe(303); | |
| 33 | return Number(/\/listings\/(\d+)/.exec(r.headers.get("location")!)![1]); | |
| 34 | }; | |
| 35 | let withFace = 0; | |
| 36 | const home = async (id?: string, q = "") => | |
| 37 | (await fetch(`${base}/${q}`, { headers: id === undefined ? {} : as(id) })).text(); | |
| 38 | ||
| 39 | test("the author's form starts with their last listing's details, editable", async () => { | |
| 40 | await launch("ann", "Old Brokerage"); | |
| 41 | withFace = await launch("ann", "Newest <Realty>", true); | |
| 42 | const html = await home("ann"); | |
| 43 | expect(html).toContain('name="agent_name" value="Agent ann"'); | |
| 44 | expect(html).toContain('name="brokerage" value="Newest <Realty>"'); | |
| 45 | expect(html).toContain('name="license" value="L-123456"'); | |
| 46 | expect(html).toContain('name="agent_phone" value="+1 (503) 555-0100"'); | |
| 47 | expect(html).toContain('name="agent_email" value="[email protected]"'); | |
| 48 | expect(html).toContain('value="forest" checked'); | |
| 49 | expect(html).not.toMatch(/name="brokerage" value="[^"]*"[^>]*readonly/); | |
| 50 | }); | |
| 51 | ||
| 52 | test("a search does not lose the prefill", async () => { | |
| 53 | expect(await home("ann", "?q=nothing-matches")).toContain('name="agent_name" value="Agent ann"'); | |
| 54 | }); | |
| 55 | ||
| 56 | test("another person, a newcomer and anon see the form empty", async () => { | |
| 57 | for (const html of [await home("bob"), await home()]) { | |
| 58 | expect(html).toContain('name="agent_name" value=""'); | |
| 59 | expect(html).not.toContain("Agent ann"); | |
| 60 | expect(html).not.toContain("Newest"); | |
| 61 | expect(html).not.toContain("Using your headshot"); | |
| 62 | } | |
| 63 | }); | |
| 64 | ||
| 65 | test("the headshot is reused and the form says whose it is", async () => { | |
| 66 | expect(await home("ann")).toContain("Using your headshot from 412 Alder Lane — choose a file to replace it"); | |
| 67 | const reused = await launch("ann", "Third"); | |
| 68 | const face = async (n: number) => { | |
| 69 | const flyer = await (await fetch(`${base}/listings/${n}/flyer`, { headers: as("ann") })).text(); | |
| 70 | return /class="flyer-face" src="[^"]*\/photos\/([0-9a-f]{32}\.png)"/.exec(flyer)?.[1]; | |
| 71 | }; | |
| 72 | expect(await face(reused)).toBeDefined(); | |
| 73 | expect(await face(reused)).toBe(await face(withFace)); | |
| 74 | }); | |
| 75 | ||
| 76 | test("when the headshot file is gone, the note goes and the launch does not reuse it", async () => { | |
| 77 | const dir = join(dataDir, "photos"); | |
| 78 | for (const f of readdirSync(dir)) rmSync(join(dir, f)); | |
| 79 | expect(await home("ann")).not.toContain("Using your headshot"); | |
| 80 | const after = await launch("ann", "Fourth"); | |
| 81 | const flyer = await (await fetch(`${base}/listings/${after}/flyer`, { headers: as("ann") })).text(); | |
| 82 | expect(flyer).not.toMatch(/class="flyer-face" src="[^"]*\/photos\//); | |
| 83 | }); |
src/routes.ts +3 −4
| @@ -64,6 +64,7 @@ import { | ||
| 64 | 64 | } from "./http.ts"; |
| 65 | 65 | import { events } from "./live.ts"; |
| 66 | 66 | import { rpc } from "./mcp.ts"; |
| 67 | import { headshotFrom, lastListing } from "./agent.ts"; | |
| 67 | 68 | import { draftOf, home, type Draft } from "./pages/home.ts"; |
| 68 | 69 | import { layout } from "./pages/layout.ts"; |
| 69 | 70 | import { factsPage } from "./pages/facts.ts"; |
| @@ -424,10 +425,7 @@ export const routes: readonly Route[] = [ | ||
| 424 | 425 | const from = whole(c.url.searchParams.get("from")); |
| 425 | 426 | const chosen = |
| 426 | 427 | from === undefined ? undefined : getListing(c.db, c.who, { id: from }); |
| 427 | const mine = | |
| 428 | c.who.role === "anon" || !listings.ok | |
| 429 | ? undefined | |
| 430 | : listings.value.find((l) => l.author_id === (c.who as { id: string }).id); | |
| 428 | const mine = lastListing(c.db, c.who); | |
| 431 | 429 | const draft: Draft = |
| 432 | 430 | chosen?.ok === true |
| 433 | 431 | ? withoutOpenHouses(draftOf(chosen.value, false)) |
| @@ -440,6 +438,7 @@ export const routes: readonly Route[] = [ | ||
| 440 | 438 | listings: listings.ok ? listings.value : [], |
| 441 | 439 | find: wanted, |
| 442 | 440 | draft, |
| 441 | headshotFrom: headshotFrom(c.db, c.who), | |
| 443 | 442 | notice, |
| 444 | 443 | }), |
| 445 | 444 | 200, |
Medium: this repeats the query in
src/actions.ts~641 and adds apathOffile check the launch does not have. If the file is gone, the form hides the note but the launch still reuses the missing file. Have the launch call one shared helper (name and file, with thepathOfcheck) and have the form read its address.