Public: anyone can read this; the crew can change it.
The property page shows the agent's other homes for sale, so a buyer who opens one link finds the rest #132
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/113-the-property-page-shows-the-agent-s-othe → main 5 commits fork 2 threads · 2 unresolved
16 files
changed +265
−22
README.md +8 −2
| @@ -160,8 +160,8 @@ to make a listing of your own. | ||
| 160 | 160 | only once you approved it. Until then everyone else gets a 404 and you see |
| 161 | 161 | "Not public yet: approve the MLS description". The link's origin is |
| 162 | 162 | `FORGE_APP_URL`, never whatever address the visitor typed; the terminal and the |
| 163 | launch kit print the same URL. The first sample's MLS text is approved so | |
| 164 | the preview shows a page. | |
| 163 | launch kit print the same URL. Two samples' MLS texts (Alder Lane and 1630 Hawthorne Terrace) are approved so | |
| 164 | the preview shows a page, with each linking the other under "More homes from". | |
| 165 | 165 | 14. **Print the seller report.** Press **Seller report** beside the flyer and |
| 166 | 166 | the kit: one page with the address, price, status, photo, the property page |
| 167 | 167 | link and a row per asset, each with "Approved by … on …" and its words, or |
| @@ -482,6 +482,12 @@ and the hero photo's absolute URL. The sample photos are SVG, which some | ||
| 482 | 482 | networks ignore when drawing a card; images are not converted, so those |
| 483 | 483 | previews show text only there. Uploaded JPEG and PNG photos are unaffected. |
| 484 | 484 | |
| 485 | Below the agent card, a property page lists up to three of the same agent's | |
| 486 | other homes for sale ("More homes from Pat Agent"): active or coming soon, | |
| 487 | newest first, each with its hero photo, address, price and beds/baths, and only | |
| 488 | those whose own page is public. No words are shown, and with none to show the | |
| 489 | section is absent. | |
| 490 | ||
| 485 | 491 | ## The shape |
| 486 | 492 | |
| 487 | 493 | One implementation, many doors. `src/actions.ts` is every operation as a |
docs/design/areas/property.md +1
| @@ -7,3 +7,4 @@ New component classes, all built from existing tokens: | ||
| 7 | 7 | - `flyer-link` — the property page address printed on the flyer. |
| 8 | 8 | - `email-cta`, `email-button` — the "See the home" button in the email. |
| 9 | 9 | - `public-head` (`public-brand`, `public-back`), `public-foot` — the agent's own header and footer on `/homes/:id`, in place of the tool's. |
| 10 | - `more-homes` (`more-home`, `more-address`, `more-price`) — the agent's other homes for sale, below the agent card on `/homes/:id`. |
src/listings.test.ts +1 −1
| @@ -72,7 +72,7 @@ test("the first screen is full: the form, and the sample listing", async () => { | ||
| 72 | 72 | // Planted once: a second boot on the same data plants nothing. |
| 73 | 73 | const again = openDb(dir); |
| 74 | 74 | seed(again); |
| 75 | expect(again.query("SELECT count(*) AS n FROM listing").get()).toEqual({ n: 3 }); | |
| 75 | expect(again.query("SELECT count(*) AS n FROM listing").get()).toEqual({ n: 4 }); | |
| 76 | 76 | }); |
| 77 | 77 | |
| 78 | 78 | test("Launch saves the listing with its photos, and it reopens at /listings/:id", async () => { |
src/morehomes.test.ts added +42
| @@ -0,0 +1,42 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { Database } from "bun:sqlite"; | |
| 3 | import { moreHomes } from "./morehomes.ts"; | |
| 4 | ||
| 5 | const db = new Database(":memory:"); | |
| 6 | db.run(`CREATE TABLE listing (id INTEGER PRIMARY KEY, author_id TEXT, address TEXT, price INTEGER, beds INTEGER, baths REAL, | |
| 7 | status TEXT, photos TEXT, photo_alt TEXT, created_at TEXT)`); | |
| 8 | db.run(`CREATE TABLE asset (listing_id INTEGER, kind TEXT, approved_at TEXT)`); | |
| 9 | let n = 0; | |
| 10 | const add = (author: string, status: string, approved = true, photos = '["a.jpg"]') => { | |
| 11 | const id = ++n; | |
| 12 | db.query("INSERT INTO listing VALUES (?1, ?2, ?3, 100, 3, 2, ?4, ?5, '{\"a.jpg\":\"Front\"}', ?6)") | |
| 13 | .run(id, author, `${id} Main St`, status, photos, `2026-01-${String(id).padStart(2, "0")}`); | |
| 14 | db.query("INSERT INTO asset VALUES (?1, 'mls', ?2)").run(id, approved ? "2026-01-01" : null); | |
| 15 | return id; | |
| 16 | }; | |
| 17 | ||
| 18 | const self = add("pat", "active"); | |
| 19 | const a = add("pat", "active"); | |
| 20 | const b = add("pat", "coming_soon", true, "[]"); | |
| 21 | for (const s of ["sold", "under_contract", "withdrawn", "expired"]) add("pat", s); | |
| 22 | add("sam", "active"); | |
| 23 | const hidden = add("pat", "active", false); | |
| 24 | ||
| 25 | test("excludes itself, off-market, unapproved and other authors", () => { | |
| 26 | const ids = moreHomes(db, { id: self, author_id: "pat" }).map((h) => h.id); | |
| 27 | expect(ids).toEqual([b, a]); | |
| 28 | expect(ids).not.toContain(self); | |
| 29 | expect(ids).not.toContain(hidden); | |
| 30 | }); | |
| 31 | ||
| 32 | test("carries the hero with its alt, or none", () => { | |
| 33 | const [first, second] = moreHomes(db, { id: self, author_id: "pat" }); | |
| 34 | expect(first!.hero).toBeNull(); | |
| 35 | expect(second!.heroAlt).toBe("Front"); | |
| 36 | }); | |
| 37 | ||
| 38 | test("caps at three, newest first", () => { | |
| 39 | const c = add("pat", "active"); | |
| 40 | const d = add("pat", "active"); | |
| 41 | expect(moreHomes(db, { id: self, author_id: "pat" }).map((h) => h.id)).toEqual([d, c, b]); | |
| 42 | }); |
src/morehomes.ts added +47
| @@ -0,0 +1,47 @@ | ||
| 1 | /** | |
| 2 | * The agent's other homes for sale: what a buyer on one property page may | |
| 3 | * follow to the next. Only facts and the hero photo leave here, and only for | |
| 4 | * homes whose own page is public (an approved MLS description). | |
| 5 | */ | |
| 6 | ||
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | ||
| 9 | export const MORE_HOMES_MAX = 3; | |
| 10 | ||
| 11 | export interface MoreHome { | |
| 12 | readonly id: number; | |
| 13 | readonly address: string; | |
| 14 | readonly price: number; | |
| 15 | readonly beds: number; | |
| 16 | readonly baths: number; | |
| 17 | readonly hero: string | null; | |
| 18 | readonly heroAlt: string | null; | |
| 19 | } | |
| 20 | ||
| 21 | /** Up to three other active or coming-soon homes by the listing's author, newest first. */ | |
| 22 | export function moreHomes(db: Database, listing: { id: number; author_id: string }): MoreHome[] { | |
| 23 | const rows = db | |
| 24 | .query< | |
| 25 | { id: number; address: string; price: number; beds: number; baths: number; photos: string; photo_alt: string }, | |
| 26 | [string, number, number] | |
| 27 | >( | |
| 28 | `SELECT l.id, l.address, l.price, l.beds, l.baths, l.photos, l.photo_alt FROM listing l | |
| 29 | WHERE l.author_id = ?1 AND l.id != ?2 AND l.status IN ('active', 'coming_soon') | |
| 30 | AND EXISTS (SELECT 1 FROM asset a WHERE a.listing_id = l.id AND a.kind = 'mls' AND a.approved_at IS NOT NULL) | |
| 31 | ORDER BY l.created_at DESC, l.id DESC LIMIT ?3`, | |
| 32 | ) | |
| 33 | .all(listing.author_id, listing.id, MORE_HOMES_MAX); | |
| 34 | return rows.map((r) => { | |
| 35 | const hero = (JSON.parse(r.photos) as string[])[0] ?? null; | |
| 36 | const alt = JSON.parse(r.photo_alt) as Record<string, string>; | |
| 37 | return { | |
| 38 | id: r.id, | |
| 39 | address: r.address, | |
| 40 | price: r.price, | |
| 41 | beds: r.beds, | |
| 42 | baths: r.baths, | |
| 43 | hero, | |
| 44 | heroAlt: hero === null ? null : (alt[hero] ?? r.address), | |
| 45 | }; | |
| 46 | }); | |
| 47 | } |
src/pages/fairhousing.ts +12 −6
| @@ -158,9 +158,8 @@ function ruleItem(rule: Rule): string { | ||
| 158 | 158 | </li>`; |
| 159 | 159 | } |
| 160 | 160 | |
| 161 | /** The page. `rules` is `RULES`; a test may pass its own table. */ | |
| 162 | export function fairHousingPage(input: { who: Who; rules?: readonly Rule[] }): string { | |
| 163 | const rules = input.rules ?? RULES; | |
| 161 | /** The page's body for a rule table: expanding every rule's phrases and running the check over each cleared phrase is its costly part. */ | |
| 162 | function bodyOf(rules: readonly Rule[]): string { | |
| 164 | 163 | const sections = (Object.keys(CLASSES) as ProtectedClass[]) |
| 165 | 164 | .map((c) => { |
| 166 | 165 | const mine = rules.filter((r) => r.touches === c); |
| @@ -174,8 +173,7 @@ export function fairHousingPage(input: { who: Who; rules?: readonly Rule[] }): s | ||
| 174 | 173 | const cleared = CLEARED.filter((c) => check(c.text, rules).clear) |
| 175 | 174 | .map((c) => `<li><b>${escape(c.text)}</b> <span class="muted">is fine: ${escape(c.because)}</span></li>`) |
| 176 | 175 | .join(""); |
| 177 | return layout( | |
| 178 | `<div class="narrow stack"> | |
| 176 | return `<div class="narrow stack"> | |
| 179 | 177 | <p class="lede">Every word an asset says is read for language that states or implies a preference by a protected class. It looks at a phrase in its context, not at a list of banned words.</p> |
| 180 | 178 | <p><b>A second pair of eyes, not legal advice.</b> It flags; it never rewrites by itself, and a person approves every word. A <span class="fh-tag">note</span> is lawful but retired by many MLSs, such as “master bedroom”; both turn the check amber.</p> |
| 181 | 179 | ${sections} |
| @@ -184,7 +182,15 @@ export function fairHousingPage(input: { who: Who; rules?: readonly Rule[] }): s | ||
| 184 | 182 | <p class="muted">The same words can describe a house or a buyer. These describe the house.</p> |
| 185 | 183 | <ul class="fh-list" aria-label="Phrases that pass">${cleared}</ul> |
| 186 | 184 | </section> |
| 187 | </div>`, | |
| 185 | </div>`; | |
| 186 | } | |
| 187 | let bodyOfRules: string | undefined; | |
| 188 | ||
| 189 | /** The page. `rules` is `RULES`; a test may pass its own table. The built-in table never changes at run time, so its body is built once. */ | |
| 190 | export function fairHousingPage(input: { who: Who; rules?: readonly Rule[] }): string { | |
| 191 | const body = input.rules === undefined ? (bodyOfRules ??= bodyOf(RULES)) : bodyOf(input.rules); | |
| 192 | return layout( | |
| 193 | body, | |
| 188 | 194 | input.who, |
| 189 | 195 | { path: "/fair-housing", heading: "What the Fair Housing check looks for" }, |
| 190 | 196 | ); |
src/pages/property.ts +12 −1
| @@ -12,6 +12,7 @@ import { escape } from "../http.ts"; | ||
| 12 | 12 | import { altOf, propertyPath, publicDescription, type Launched } from "../listing.ts"; |
| 13 | 13 | import type { Who } from "../who.ts"; |
| 14 | 14 | import { monthlyPayment, PAYMENT_NOTE } from "../payment.ts"; |
| 15 | import type { MoreHome } from "../morehomes.ts"; | |
| 15 | 16 | import { inquiryLinks } from "../inquiry.ts"; |
| 16 | 17 | import { layout } from "./layout.ts"; |
| 17 | 18 | import { photoUrl } from "./listing.ts"; |
| @@ -74,7 +75,16 @@ function paymentLines(l: Launched): string { | ||
| 74 | 75 | <p class="muted home-payment-note"><small>${escape(PAYMENT_NOTE)}</small></p>`; |
| 75 | 76 | } |
| 76 | 77 | |
| 77 | export function propertyPage(input: { who: Who; listing: Launched }): string { | |
| 78 | /** The agent's other homes for sale: a photo, address, price and beds/baths each; absent with none. */ | |
| 79 | function moreSection(l: Launched, homes: readonly MoreHome[]): string { | |
| 80 | if (homes.length === 0) return ""; | |
| 81 | const card = (h: MoreHome) => `<li class="card more-home"><a href="/homes/${h.id}">${ | |
| 82 | h.hero === null ? "" : `<img src="${escape(photoUrl(h, h.hero))}" alt="${escape(h.heroAlt ?? h.address)}" width="240" height="160" loading="lazy">` | |
| 83 | }<span class="more-address">${escape(h.address)}</span><span class="more-price brand">${money(h.price)}</span><span class="muted">${escape(String(h.beds))} bd · ${escape(String(h.baths))} ba</span></a></li>`; | |
| 84 | return `<section class="more-homes" aria-labelledby="more-homes-h"><h3 id="more-homes-h">More homes from ${escape(l.agent_name)}</h3><ul>${homes.map(card).join("")}</ul></section>`; | |
| 85 | } | |
| 86 | ||
| 87 | export function propertyPage(input: { who: Who; listing: Launched; more?: readonly MoreHome[] }): string { | |
| 78 | 88 | const { who, listing: l } = input; |
| 79 | 89 | if (isOffMarket(l.status)) return offMarketPage(who, l); |
| 80 | 90 | const words = publicDescription(l); |
| @@ -98,6 +108,7 @@ export function propertyPage(input: { who: Who; listing: Launched }): string { | ||
| 98 | 108 | ${words === null ? "" : `<p class="home-words">${escape(words).replace(/\n/g, "<br>")}</p>`} |
| 99 | 109 | ${more.length === 0 ? "" : `<ul class="strip">${more.map((p, i) => `<li><img src="${escape(photoUrl(l, p))}" alt="${escape(altOf(l, p, `Photo ${i + 2} of ${l.address}`))}" width="240" height="160" loading="lazy"></li>`).join("")}</ul>`} |
| 100 | 110 | ${agentFooter(l)} |
| 111 | ${moreSection(l, input.more ?? [])} | |
| 101 | 112 | </article>`, |
| 102 | 113 | who, |
| 103 | 114 | { |
src/photoalt.test.ts +1 −1
| @@ -113,7 +113,7 @@ test("removing a photo removes its description; empty text takes one away", () = | ||
| 113 | 113 | test("the planted samples are described", () => { |
| 114 | 114 | seed(db); |
| 115 | 115 | const rows = db.query<{ photo_alt: string }, []>("SELECT photo_alt FROM listing WHERE author_id = 'sample'").all(); |
| 116 | expect(rows.length).toBe(3); | |
| 116 | expect(rows.length).toBe(4); | |
| 117 | 117 | for (const r of rows) expect(Object.keys(JSON.parse(r.photo_alt)).length).toBe(2); |
| 118 | 118 | }); |
| 119 | 119 |
src/property.test.ts +22
| @@ -261,6 +261,28 @@ test("the off-market page wears the agent's frame: no sign-in, a way back for th | ||
| 261 | 261 | expect(await (await call(`/homes/${id}`, "pat")).text()).toContain(`href="/listings/${id}"`); |
| 262 | 262 | }); |
| 263 | 263 | |
| 264 | test("a sample property page links the agent's other sample home, and only that", async () => { | |
| 265 | // Samples plant in order: 1 Alder Lane, 2 Hawthorne Terrace, 3 Meridian (sold), 4 Birchwood (words not approved). | |
| 266 | const alder = await (await call("/homes/1")).text(); | |
| 267 | expect(alder).toContain("More homes from Morgan Reyes"); | |
| 268 | expect(alder).toContain('href="/homes/2"'); | |
| 269 | expect(alder).toContain("1630 Hawthorne Terrace"); | |
| 270 | expect(alder).not.toContain('href="/homes/1"'); // not itself | |
| 271 | expect(alder).not.toContain('href="/homes/3"'); // sold | |
| 272 | expect(alder).not.toContain('href="/homes/4"'); // not public | |
| 273 | expect(alder).not.toContain(`href="/homes/${id}"`); // another author's public home | |
| 274 | const hawthorne = await (await call("/homes/2")).text(); | |
| 275 | expect(hawthorne).toContain("More homes from Morgan Reyes"); | |
| 276 | expect(hawthorne).toContain('href="/homes/1"'); | |
| 277 | expect(hawthorne).toContain("412 Alder Lane"); | |
| 278 | expect(hawthorne).not.toContain('href="/homes/2"'); | |
| 279 | expect(hawthorne).not.toContain(`href="/homes/${id}"`); | |
| 280 | }); | |
| 281 | ||
| 282 | test("a property page with no other public home by its agent shows no more-homes section", async () => { | |
| 283 | expect(await (await call(`/homes/${id}`)).text()).not.toContain("More homes from"); | |
| 284 | }); | |
| 285 | ||
| 264 | 286 | // --- one-tap inquiry -------------------------------------------------------- |
| 265 | 287 | const hrefs = (html: string) => [...html.matchAll(/<a class="button[^"]*" href="((?:sms|mailto):[^"]*)">/g)].map((m) => m[1]!.replace(/&/g, "&").replace(/'/g, "'")); |
| 266 | 288 | const approved = (over: Record<string, unknown> = {}) => { |
src/routes.ts +2 −1
| @@ -82,6 +82,7 @@ import { factsPage } from "./pages/facts.ts"; | ||
| 82 | 82 | import { INTERESTS, SHOWING_AGENT_MAX, SHOWING_FEEDBACK_MAX } from "./showings.ts"; |
| 83 | 83 | import { emailPage } from "./pages/email.ts"; |
| 84 | 84 | import { publicOrigin } from "./env.ts"; |
| 85 | import { moreHomes } from "./morehomes.ts"; | |
| 85 | 86 | import { propertyPage } from "./pages/property.ts"; |
| 86 | 87 | import { countLine } from "./postlength.ts"; |
| 87 | 88 | import { reportPage } from "./pages/report.ts"; |
| @@ -624,7 +625,7 @@ export const routes: readonly Route[] = [ | ||
| 624 | 625 | method: "GET", |
| 625 | 626 | path: "/homes/:id", |
| 626 | 627 | handle: (c) => { |
| 627 | return page(c.who, getHome(c.db, c.who, { id: Number(c.params.id) }), (listing) => propertyPage({ who: c.who, listing })); | |
| 628 | return page(c.who, getHome(c.db, c.who, { id: Number(c.params.id) }), (listing) => propertyPage({ who: c.who, listing, more: moreHomes(c.db, listing) })); | |
| 628 | 629 | }, |
| 629 | 630 | }, |
| 630 | 631 | { |
src/sample.test.ts +6 −6
| @@ -15,22 +15,22 @@ const fresh = () => openDb(mkdtempSync(join(tmpdir(), "sample-"))); | ||
| 15 | 15 | const count = (db: ReturnType<typeof fresh>) => |
| 16 | 16 | (db.query("SELECT count(*) AS n FROM listing").get() as { n: number }).n; |
| 17 | 17 | |
| 18 | test("a fresh database has three samples, and seeding twice keeps three", () => { | |
| 18 | test("a fresh database has four samples, and seeding twice keeps four", () => { | |
| 19 | 19 | const db = fresh(); |
| 20 | 20 | seed(db); |
| 21 | expect(count(db)).toBe(3); | |
| 21 | expect(count(db)).toBe(4); | |
| 22 | 22 | seed(db); |
| 23 | expect(count(db)).toBe(3); | |
| 23 | expect(count(db)).toBe(4); | |
| 24 | 24 | }); |
| 25 | 25 | |
| 26 | test("a database that only had Alder Lane gets the other two, not a second Alder Lane", () => { | |
| 26 | test("a database that only had Alder Lane gets the other three, not a second Alder Lane", () => { | |
| 27 | 27 | const db = fresh(); |
| 28 | 28 | seed(db); |
| 29 | 29 | db.run("DELETE FROM listing WHERE address NOT LIKE '412 Alder%'"); |
| 30 | 30 | db.run("DELETE FROM seeded WHERE name != 'sample'"); |
| 31 | 31 | expect(count(db)).toBe(1); |
| 32 | 32 | seed(db); |
| 33 | expect(count(db)).toBe(3); | |
| 33 | expect(count(db)).toBe(4); | |
| 34 | 34 | expect(db.query("SELECT count(*) AS n FROM listing WHERE address LIKE '412 Alder%'").get()).toEqual({ n: 1 }); |
| 35 | 35 | }); |
| 36 | 36 | |
| @@ -113,7 +113,7 @@ test("listings that predate the video script get it on boot, and keep every othe | ||
| 113 | 113 | expect((db.query("SELECT count(*) AS n FROM asset WHERE kind = 'video_script'").get() as { n: number }).n).toBe(0); |
| 114 | 114 | seed(db); |
| 115 | 115 | const rows = db.query("SELECT a.approved_via AS via, l.address AS address FROM asset a JOIN listing l ON l.id = a.listing_id WHERE a.kind = 'video_script'").all() as { via: string | null; address: string }[]; |
| 116 | expect(rows).toHaveLength(3); | |
| 116 | expect(rows).toHaveLength(4); | |
| 117 | 117 | expect(rows.find((r) => r.address.startsWith("412 Alder"))!.via).toBe("sample"); |
| 118 | 118 | expect(rows.filter((r) => r.via === null)).toHaveLength(2); |
| 119 | 119 | expect(approved()).toBe(before); |
src/sample.ts +46 −3
| @@ -102,6 +102,34 @@ export const SAMPLE_HOUSE: ListingInput = { | ||
| 102 | 102 | agent_email: "[email protected]", |
| 103 | 103 | }; |
| 104 | 104 | |
| 105 | /** A second active Portland home for the same agent as Alder Lane, so a sample property page has another home to link to. */ | |
| 106 | export const SAMPLE_BUNGALOW: ListingInput = { | |
| 107 | address: "1630 Hawthorne Terrace, Portland, OR 97214", | |
| 108 | beds: 2, | |
| 109 | baths: 1, | |
| 110 | sqft: 1380, | |
| 111 | lot: "0.12 acre", | |
| 112 | year: 1936, | |
| 113 | price: 524000, | |
| 114 | features: [ | |
| 115 | "Deep front porch under the original gabled roof", | |
| 116 | "Refinished fir floors and a brick fireplace in the living room", | |
| 117 | "Updated kitchen with a farmhouse sink and open shelving", | |
| 118 | "Fenced back yard with a vegetable bed and a tool shed", | |
| 119 | "Bonus room off the kitchen with a skylight", | |
| 120 | "Newer furnace and a heat pump installed in 2022", | |
| 121 | "Three blocks to the Hawthorne shops and the number 14 bus", | |
| 122 | ], | |
| 123 | tone: "warm", | |
| 124 | palette: "forest", | |
| 125 | open_houses: [], | |
| 126 | agent_name: "Morgan Reyes", | |
| 127 | brokerage: "Harbor & Pine Realty", | |
| 128 | license: "OR 201255501", | |
| 129 | agent_phone: "(503) 555-0142", | |
| 130 | agent_email: "[email protected]", | |
| 131 | }; | |
| 132 | ||
| 105 | 133 | interface Sample { |
| 106 | 134 | /** The `seeded` row: once present, never planted again. */ |
| 107 | 135 | readonly name: string; |
| @@ -110,6 +138,8 @@ interface Sample { | ||
| 110 | 138 | readonly headshot: string; |
| 111 | 139 | /** What each photo shows, in the order of `photos`: their alt text. */ |
| 112 | 140 | readonly alt: readonly string[]; |
| 141 | /** Opens finished: every asset signed off by its sample agent. */ | |
| 142 | readonly signed?: true; | |
| 113 | 143 | /** Where the sample stands; absent means active. */ |
| 114 | 144 | readonly sold_at?: number; |
| 115 | 145 | /** Its time on the market: days ago it went active, the price it listed at, each cut as days ago and the price it went to, and days ago it left the market. */ |
| @@ -119,7 +149,7 @@ interface Sample { | ||
| 119 | 149 | } |
| 120 | 150 | |
| 121 | 151 | const SAMPLES: readonly Sample[] = [ |
| 122 | { name: "sample", listing: { ...SAMPLE, open_houses: [SAMPLE_OPEN_HOUSE] }, photos: SAMPLE_PHOTOS, headshot: SAMPLE_HEADSHOT, alt: ["Front of 412 Alder Lane with a covered porch", "Back garden with raised beds and a patio"], | |
| 152 | { name: "sample", signed: true, listing: { ...SAMPLE, open_houses: [SAMPLE_OPEN_HOUSE] }, photos: SAMPLE_PHOTOS, headshot: SAMPLE_HEADSHOT, alt: ["Front of 412 Alder Lane with a covered porch", "Back garden with raised beds and a patio"], | |
| 123 | 153 | market: { listed: 38, list_price: 709000, cuts: [[17, 689000]] }, |
| 124 | 154 | showings: [ |
| 125 | 155 | [9, "Priya Nair, Larkspur Realty", "interested", "Loved the kitchen and the oak floors; wants a second look with her clients."], |
| @@ -127,6 +157,19 @@ const SAMPLES: readonly Sample[] = [ | ||
| 127 | 157 | [2, "Dana Whitcomb, Harbor & Pine", "offer_likely", "Second visit, measured the basement; expects to write an offer this week."], |
| 128 | 158 | ], |
| 129 | 159 | }, |
| 160 | { | |
| 161 | name: "sample-bungalow", | |
| 162 | signed: true, | |
| 163 | listing: SAMPLE_BUNGALOW, | |
| 164 | photos: ["sample-bungalow-front.svg", "sample-bungalow-yard.svg"], | |
| 165 | headshot: SAMPLE_HEADSHOT, | |
| 166 | alt: ["Bungalow with a deep front porch", "Fenced back yard with a vegetable bed"], | |
| 167 | market: { listed: 9, list_price: 524000, cuts: [] }, | |
| 168 | showings: [ | |
| 169 | [6, "Elena Park, Larkspur Realty", "interested", "Liked the porch and the fir floors; wants to bring her clients."], | |
| 170 | [2, "Sam Ortiz, Eastside Homes", "maybe", "Sweet yard; wants a third bedroom."], | |
| 171 | ], | |
| 172 | }, | |
| 130 | 173 | { |
| 131 | 174 | name: "sample-condo", |
| 132 | 175 | listing: SAMPLE_CONDO, |
| @@ -180,8 +223,8 @@ export function seed(db: Database): void { | ||
| 180 | 223 | made.id, |
| 181 | 224 | JSON.stringify(Object.fromEntries(s.photos.map((name, i) => [name, s.alt[i]]))), |
| 182 | 225 | ); |
| 183 | // Alder Lane opens finished: every asset signed off by its sample agent, never by a person's door. | |
| 184 | if (s.name === "sample") signOff(db, made.id, `${s.listing.agent_name} (sample)`); | |
| 226 | // The signed samples open finished: every asset signed off by its sample agent, never by a person's door. | |
| 227 | if (s.signed) signOff(db, made.id, `${s.listing.agent_name} (sample)`); | |
| 185 | 228 | } |
| 186 | 229 | // A sample planted before the video script existed gets it, signed like the rest it was planted with. |
| 187 | 230 | for (const id of draftMissingVideoScripts(db)) { |
src/showings.test.ts +1 −1
| @@ -147,7 +147,7 @@ test("samples come with showings", () => { | ||
| 147 | 147 | const db = openDb(mkdtempSync(join(tmpdir(), "app-showings-seed-"))); |
| 148 | 148 | seed(db); |
| 149 | 149 | const per = db.query<{ n: number }, []>("SELECT count(*) AS n FROM showing GROUP BY listing_id").all(); |
| 150 | expect(per.length).toBe(3); | |
| 150 | expect(per.length).toBe(4); | |
| 151 | 151 | for (const p of per) expect(p.n).toBeGreaterThanOrEqual(2); |
| 152 | 152 | seed(db); |
| 153 | 153 | expect(db.query<{ n: number }, []>("SELECT count(*) AS n FROM showing").get()!.n).toBe(per.reduce((a, p) => a + p.n, 0)); |
static/app.css +25
| @@ -1474,3 +1474,28 @@ main:focus { | ||
| 1474 | 1474 | min-height: calc(var() * 1.5); |
| 1475 | 1475 | font-size: 1.5rem; |
| 1476 | 1476 | } |
| 1477 | ||
| 1478 | .more-homes ul { | |
| 1479 | list-style: none; | |
| 1480 | margin: 0; | |
| 1481 | padding: 0; | |
| 1482 | display: grid; | |
| 1483 | grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); | |
| 1484 | gap: var(); | |
| 1485 | } | |
| 1486 | .more-home a { | |
| 1487 | display: flex; | |
| 1488 | flex-direction: column; | |
| 1489 | gap: var(); | |
| 1490 | color: inherit; | |
| 1491 | text-decoration: none; | |
| 1492 | } | |
| 1493 | .more-home img { | |
| 1494 | width: 100%; | |
| 1495 | height: auto; | |
| 1496 | aspect-ratio: 3 / 2; | |
| 1497 | object-fit: cover; | |
| 1498 | } | |
| 1499 | .more-address { | |
| 1500 | font-weight: 600; | |
| 1501 | } |
static/sample-bungalow-front.svg added +25
| @@ -0,0 +1,25 @@ | ||
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800" width="1200" height="800" role="img" aria-label="Illustration: a single-storey bungalow with a deep front porch"> | |
| 2 | <defs> | |
| 3 | <linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f3c99a"/><stop offset=".55" stop-color="#f7e3c6"/><stop offset="1" stop-color="#eef0e6"/></linearGradient> | |
| 4 | <linearGradient id="lawn" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#7f9a63"/><stop offset="1" stop-color="#5d7a47"/></linearGradient> | |
| 5 | </defs> | |
| 6 | <rect width="1200" height="800" fill="url(#sky)"/> | |
| 7 | <circle cx="930" cy="210" r="70" fill="#fbe7c4"/> | |
| 8 | <g fill="#9cae86" opacity=".7"><ellipse cx="120" cy="470" rx="190" ry="150"/><ellipse cx="1110" cy="460" rx="200" ry="170"/></g> | |
| 9 | <rect y="560" width="1200" height="240" fill="url(#lawn)"/> | |
| 10 | <path d="M300 330 L600 170 L900 330 Z" fill="#3e4a52"/> | |
| 11 | <rect x="330" y="330" width="540" height="250" fill="#e9dcc4"/> | |
| 12 | <path d="M300 330 h600 v18 h-600z" fill="#2f383e"/> | |
| 13 | <path d="M470 280 L600 215 L730 280 Z" fill="#e9dcc4"/> | |
| 14 | <rect x="566" y="242" width="68" height="38" fill="#f6d58e"/> | |
| 15 | <g fill="#f6d58e" stroke="#ffffff" stroke-width="8"><rect x="370" y="380" width="130" height="110"/><rect x="700" y="380" width="130" height="110"/></g> | |
| 16 | <g stroke="#ffffff" stroke-width="4"><path d="M435 380v110M370 435h130M765 380v110M700 435h130"/></g> | |
| 17 | <rect x="555" y="400" width="90" height="180" fill="#2d5a43"/> | |
| 18 | <circle cx="628" cy="490" r="5" fill="#f6d58e"/> | |
| 19 | <path d="M520 380 h160 v20 h-160z" fill="#2f383e"/> | |
| 20 | <rect x="525" y="400" width="14" height="180" fill="#d7c7aa"/><rect x="661" y="400" width="14" height="180" fill="#d7c7aa"/> | |
| 21 | <path d="M540 580 h120 l40 220 h-200z" fill="#cdbb9b"/> | |
| 22 | <g fill="#4e6b3c"><ellipse cx="340" cy="575" rx="70" ry="38"/><ellipse cx="860" cy="575" rx="70" ry="38"/></g> | |
| 23 | <g fill="#c9775b"><circle cx="320" cy="560" r="7"/><circle cx="352" cy="566" r="6"/><circle cx="880" cy="562" r="7"/><circle cx="846" cy="568" r="6"/></g> | |
| 24 | <rect x="150" y="300" width="22" height="290" fill="#6b5440"/><ellipse cx="161" cy="300" rx="110" ry="120" fill="#6f8b55"/> | |
| 25 | </svg> |
static/sample-bungalow-yard.svg added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800" width="1200" height="800" role="img" aria-label="Illustration: a fenced back yard with a vegetable bed"> | |
| 2 | <rect width="1200" height="800" fill="#e7eedc"/> | |
| 3 | <rect y="430" width="1200" height="370" fill="#89a56c"/> | |
| 4 | <path d="M0 430 h1200 v20 h-1200z" fill="#6d8a55"/> | |
| 5 | <g fill="#7b5b3f"><rect x="120" y="520" width="300" height="90" rx="6"/><rect x="480" y="560" width="300" height="90" rx="6"/></g> | |
| 6 | <g fill="#4e6b3c"><ellipse cx="190" cy="515" rx="55" ry="32"/><ellipse cx="280" cy="505" rx="45" ry="40"/><ellipse cx="365" cy="515" rx="50" ry="30"/><ellipse cx="560" cy="555" rx="55" ry="30"/><ellipse cx="660" cy="548" rx="50" ry="36"/><ellipse cx="740" cy="556" rx="40" ry="28"/></g> | |
| 7 | <g fill="#d0573f"><circle cx="270" cy="495" r="9"/><circle cx="295" cy="510" r="8"/><circle cx="650" cy="540" r="8"/></g> | |
| 8 | <path d="M840 300 h300 v20 h-300z" fill="#3e4a52"/> | |
| 9 | <g fill="#5b4636"><rect x="850" y="320" width="16" height="250"/><rect x="1114" y="320" width="16" height="250"/></g> | |
| 10 | <rect x="840" y="570" width="300" height="24" fill="#cdbb9b"/> | |
| 11 | <g fill="#2d5a43"><rect x="900" y="500" width="70" height="10"/><rect x="1000" y="500" width="70" height="10"/></g> | |
| 12 | <rect x="960" y="480" width="50" height="70" rx="6" fill="#e9dcc4"/> | |
| 13 | <rect x="60" y="180" width="26" height="260" fill="#6b5440"/><ellipse cx="73" cy="180" rx="130" ry="140" fill="#6f8b55"/> | |
| 14 | </svg> |
Query is fine. The issue is only that no sample data satisfies it, so the section never renders on a fresh install.