Public: anyone can read this; the crew can change it.
Add an open house to a listing: its date and time on the flyer, the email and every social post #40
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/34-add-an-open-house-to-a-listing-its-date → main 2 commits fork 4 threads · 4 unresolved
16 files
changed +605
−28
README.md +10 −1
| @@ -31,7 +31,14 @@ to make a listing of your own. | ||
| 31 | 31 | press **Save and write all again**: the new price is on the flyer, every |
| 32 | 32 | asset is written again from the facts (your edits are replaced), and the |
| 33 | 33 | approval is cleared, so it says "0 of 6 approved". The photos stay. |
| 34 | 7. **Print the flyer.** Press **Print flyer**: a one-page flyer opens with | |
| 34 | 7. **Add an open house.** Press **Edit facts** again. Under "Open house" | |
| 35 | pick a date, a start and an end time (Alder Lane's sample already has one), | |
| 36 | then press **Save and write all again**. The flyer gets a band under the | |
| 37 | price, the email a line above its body, and the MLS description and every | |
| 38 | post a closing line, such as "Open Sunday, March 7, 1–3 pm", all in the | |
| 39 | listing's tone. Clearing the three boxes takes it off again. The times are | |
| 40 | kept as you type them: no time zone is applied. | |
| 41 | 8. **Print the flyer.** Press **Print flyer**: a one-page flyer opens with | |
| 35 | 42 | the photo, price, facts, description and your name. Print it or save it |
| 36 | 43 | as a PDF. |
| 37 | 44 | |
| @@ -235,6 +242,8 @@ curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' - | ||
| 235 | 242 | curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"tone":"bold"}' |
| 236 | 243 | # change facts (any launch field); every asset is written again, approvals cleared |
| 237 | 244 | curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"facts":{"price":649000}}' |
| 245 | # an open house (up to two; local date and 24-hour times as typed; [] clears) | |
| 246 | curl -X PATCH https://<app>/api/listings/1 -H 'content-type: application/json' -d '{"facts":{"open_houses":[{"date":"2027-03-07","start":"13:00","end":"15:00"}]}}' | |
| 238 | 247 | curl -X DELETE https://<app>/api/listings/1 |
| 239 | 248 | |
| 240 | 249 |
contract/openapi.json +110
| @@ -397,6 +397,33 @@ | ||
| 397 | 397 | "type": "string", |
| 398 | 398 | "format": "email", |
| 399 | 399 | "maxLength": 120 |
| 400 | }, | |
| 401 | "open_houses": { | |
| 402 | "type": "array", | |
| 403 | "maxItems": 2, | |
| 404 | "description": "Up to two open houses, in local time as typed: a real date (YYYY-MM-DD), start and end as HH:MM with the end after the start. An empty list clears them.", | |
| 405 | "items": { | |
| 406 | "type": "object", | |
| 407 | "properties": { | |
| 408 | "date": { | |
| 409 | "type": "string", | |
| 410 | "format": "date" | |
| 411 | }, | |
| 412 | "start": { | |
| 413 | "type": "string", | |
| 414 | "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" | |
| 415 | }, | |
| 416 | "end": { | |
| 417 | "type": "string", | |
| 418 | "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" | |
| 419 | } | |
| 420 | }, | |
| 421 | "required": [ | |
| 422 | "date", | |
| 423 | "start", | |
| 424 | "end" | |
| 425 | ] | |
| 426 | } | |
| 400 | 427 | } |
| 401 | 428 | }, |
| 402 | 429 | "required": [ |
| @@ -618,6 +645,33 @@ | ||
| 618 | 645 | "type": "string", |
| 619 | 646 | "format": "email", |
| 620 | 647 | "maxLength": 120 |
| 648 | }, | |
| 649 | "open_houses": { | |
| 650 | "type": "array", | |
| 651 | "maxItems": 2, | |
| 652 | "description": "Up to two open houses, in local time as typed: a real date (YYYY-MM-DD), start and end as HH:MM with the end after the start. An empty list clears them.", | |
| 653 | "items": { | |
| 654 | "type": "object", | |
| 655 | "properties": { | |
| 656 | "date": { | |
| 657 | "type": "string", | |
| 658 | "format": "date" | |
| 659 | }, | |
| 660 | "start": { | |
| 661 | "type": "string", | |
| 662 | "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" | |
| 663 | }, | |
| 664 | "end": { | |
| 665 | "type": "string", | |
| 666 | "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" | |
| 667 | } | |
| 668 | }, | |
| 669 | "required": [ | |
| 670 | "date", | |
| 671 | "start", | |
| 672 | "end" | |
| 673 | ] | |
| 674 | } | |
| 621 | 675 | } |
| 622 | 676 | } |
| 623 | 677 | }, |
| @@ -1064,6 +1118,33 @@ | ||
| 1064 | 1118 | "format": "email", |
| 1065 | 1119 | "maxLength": 120 |
| 1066 | 1120 | }, |
| 1121 | "open_houses": { | |
| 1122 | "type": "array", | |
| 1123 | "maxItems": 2, | |
| 1124 | "description": "Up to two open houses, in local time as typed: a real date (YYYY-MM-DD), start and end as HH:MM with the end after the start. An empty list clears them.", | |
| 1125 | "items": { | |
| 1126 | "type": "object", | |
| 1127 | "properties": { | |
| 1128 | "date": { | |
| 1129 | "type": "string", | |
| 1130 | "format": "date" | |
| 1131 | }, | |
| 1132 | "start": { | |
| 1133 | "type": "string", | |
| 1134 | "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" | |
| 1135 | }, | |
| 1136 | "end": { | |
| 1137 | "type": "string", | |
| 1138 | "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" | |
| 1139 | } | |
| 1140 | }, | |
| 1141 | "required": [ | |
| 1142 | "date", | |
| 1143 | "start", | |
| 1144 | "end" | |
| 1145 | ] | |
| 1146 | } | |
| 1147 | }, | |
| 1067 | 1148 | "photos": { |
| 1068 | 1149 | "type": "array", |
| 1069 | 1150 | "items": { |
| @@ -1242,6 +1323,7 @@ | ||
| 1242 | 1323 | "license", |
| 1243 | 1324 | "agent_phone", |
| 1244 | 1325 | "agent_email", |
| 1326 | "open_houses", | |
| 1245 | 1327 | "photos", |
| 1246 | 1328 | "headshot", |
| 1247 | 1329 | "created_at", |
| @@ -1344,6 +1426,33 @@ | ||
| 1344 | 1426 | "format": "email", |
| 1345 | 1427 | "maxLength": 120 |
| 1346 | 1428 | }, |
| 1429 | "open_houses": { | |
| 1430 | "type": "array", | |
| 1431 | "maxItems": 2, | |
| 1432 | "description": "Up to two open houses, in local time as typed: a real date (YYYY-MM-DD), start and end as HH:MM with the end after the start. An empty list clears them.", | |
| 1433 | "items": { | |
| 1434 | "type": "object", | |
| 1435 | "properties": { | |
| 1436 | "date": { | |
| 1437 | "type": "string", | |
| 1438 | "format": "date" | |
| 1439 | }, | |
| 1440 | "start": { | |
| 1441 | "type": "string", | |
| 1442 | "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" | |
| 1443 | }, | |
| 1444 | "end": { | |
| 1445 | "type": "string", | |
| 1446 | "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" | |
| 1447 | } | |
| 1448 | }, | |
| 1449 | "required": [ | |
| 1450 | "date", | |
| 1451 | "start", | |
| 1452 | "end" | |
| 1453 | ] | |
| 1454 | } | |
| 1455 | }, | |
| 1347 | 1456 | "photos": { |
| 1348 | 1457 | "type": "array", |
| 1349 | 1458 | "items": { |
| @@ -1415,6 +1524,7 @@ | ||
| 1415 | 1524 | "license", |
| 1416 | 1525 | "agent_phone", |
| 1417 | 1526 | "agent_email", |
| 1527 | "open_houses", | |
| 1418 | 1528 | "photos", |
| 1419 | 1529 | "headshot", |
| 1420 | 1530 | "created_at", |
docs/design/areas/open-house.md added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | # Open house | |
| 2 | ||
| 3 | New component classes, all built from existing tokens: | |
| 4 | ||
| 5 | - `flyer-open` — the band under the price on the flyer (`static/app.css`); it also carries `brand-fill`, the agent's colour. | |
| 6 | - `listing-open` — the open house line under the facts in a listing's head. | |
| 7 | - `open-grid` — the form's date / start / end row, a `facts-grid` with the same columns. |
src/actions.ts +8 −4
| @@ -19,6 +19,7 @@ import { | ||
| 19 | 19 | fromRow, |
| 20 | 20 | isKind, |
| 21 | 21 | isTone, |
| 22 | touchesOpenHouses, | |
| 22 | 23 | validate, |
| 23 | 24 | type AssetRow, |
| 24 | 25 | type Caller, |
| @@ -325,7 +326,7 @@ export interface Upload { | ||
| 325 | 326 | } |
| 326 | 327 | |
| 327 | 328 | const LISTING_COLUMNS = |
| 328 | "address, beds, baths, sqft, lot, year, price, features, tone, palette, agent_name, brokerage, license, agent_phone, agent_email"; | |
| 329 | "address, beds, baths, sqft, lot, year, price, features, tone, palette, agent_name, brokerage, license, agent_phone, agent_email, open_houses"; | |
| 329 | 330 | |
| 330 | 331 | /** A listing with its assets, or nothing. */ |
| 331 | 332 | function launched(db: Database, id: number): Launched | undefined { |
| @@ -365,7 +366,7 @@ export function insertListing( | ||
| 365 | 366 | const row = db |
| 366 | 367 | .query<ListingRow, (string | number | null)[]>( |
| 367 | 368 | `INSERT INTO listing (author_id, author_name, ${LISTING_COLUMNS}, photos, headshot) |
| 368 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19) RETURNING *`, | |
| 369 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20) RETURNING *`, | |
| 369 | 370 | ) |
| 370 | 371 | .get( |
| 371 | 372 | author.id, |
| @@ -385,6 +386,7 @@ export function insertListing( | ||
| 385 | 386 | input.license, |
| 386 | 387 | input.agent_phone, |
| 387 | 388 | input.agent_email, |
| 389 | JSON.stringify(input.open_houses), | |
| 388 | 390 | JSON.stringify(photos), |
| 389 | 391 | headshot, |
| 390 | 392 | )!; |
| @@ -794,6 +796,8 @@ function changeFacts( | ||
| 794 | 796 | license: now.license, |
| 795 | 797 | agent_phone: now.agent_phone, |
| 796 | 798 | agent_email: now.agent_email, |
| 799 | // Left out, the open houses stay; the form's open1_… fields or `open_houses` replace them. | |
| 800 | ...(touchesOpenHouses(input.fields) ? {} : { open_houses: now.open_houses }), | |
| 797 | 801 | ...input.fields, |
| 798 | 802 | }); |
| 799 | 803 | if (!valid.ok) return fail("invalid", valid.message); |
| @@ -802,11 +806,11 @@ function changeFacts( | ||
| 802 | 806 | db.query( |
| 803 | 807 | `UPDATE listing SET address = ?1, beds = ?2, baths = ?3, sqft = ?4, lot = ?5, year = ?6, price = ?7, |
| 804 | 808 | features = ?8, tone = ?9, palette = ?10, agent_name = ?11, brokerage = ?12, license = ?13, |
| 805 | agent_phone = ?14, agent_email = ?15 WHERE id = ?16`, | |
| 809 | agent_phone = ?14, agent_email = ?15, open_houses = ?16 WHERE id = ?17`, | |
| 806 | 810 | ).run( |
| 807 | 811 | v.address, v.beds, v.baths, v.sqft, v.lot, v.year, v.price, |
| 808 | 812 | JSON.stringify(v.features), v.tone, v.palette, v.agent_name, v.brokerage, |
| 809 | v.license, v.agent_phone, v.agent_email, now.id, | |
| 813 | v.license, v.agent_phone, v.agent_email, JSON.stringify(v.open_houses), now.id, | |
| 810 | 814 | ); |
| 811 | 815 | touch(db, now.id); |
| 812 | 816 | writeAssets(db, { ...now, ...v }); |
src/db.ts +2
| @@ -90,6 +90,8 @@ export const migrations: string[] = [ | ||
| 90 | 90 | `ALTER TABLE asset ADD COLUMN approved_at TEXT`, |
| 91 | 91 | // Which door the sign-off came through (page, api, tty); null before this was recorded. |
| 92 | 92 | `ALTER TABLE asset ADD COLUMN approved_via TEXT`, |
| 93 | // Up to two open houses, as typed: a JSON list of { date, start, end }, local time, never converted. | |
| 94 | `ALTER TABLE listing ADD COLUMN open_houses TEXT NOT NULL DEFAULT '[]'`, | |
| 93 | 95 | ]; |
| 94 | 96 | |
| 95 | 97 | export function openDb(dir = dataDir): Database { |
src/generate.ts +75 −8
| @@ -33,6 +33,13 @@ export const LABELS: Readonly<Record<Kind, string>> = { | ||
| 33 | 33 | email: "Email blast", |
| 34 | 34 | }; |
| 35 | 35 | |
| 36 | /** One open house, as typed: the local date (YYYY-MM-DD) and 24-hour times (HH:MM). Never converted. */ | |
| 37 | export interface OpenHouse { | |
| 38 | readonly date: string; | |
| 39 | readonly start: string; | |
| 40 | readonly end: string; | |
| 41 | } | |
| 42 | ||
| 36 | 43 | export interface Facts { |
| 37 | 44 | readonly address: string; |
| 38 | 45 | readonly beds: number; |
| @@ -43,6 +50,7 @@ export interface Facts { | ||
| 43 | 50 | readonly price: number; |
| 44 | 51 | readonly features: readonly string[]; |
| 45 | 52 | readonly tone: Tone; |
| 53 | readonly open_houses?: readonly OpenHouse[]; | |
| 46 | 54 | } |
| 47 | 55 | |
| 48 | 56 | export interface Agent { |
| @@ -123,10 +131,55 @@ export const factsLine = (f: Facts): string => { | ||
| 123 | 131 | ].join(" · "); |
| 124 | 132 | }; |
| 125 | 133 | |
| 134 | const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; | |
| 135 | const MONTHS = [ | |
| 136 | "January", "February", "March", "April", "May", "June", | |
| 137 | "July", "August", "September", "October", "November", "December", | |
| 138 | ]; | |
| 139 | ||
| 140 | /** "13:30" → { h: 1, m: "30", pm: true }, on a 12-hour clock. */ | |
| 141 | const clock = (t: string): { h: number; m: string; pm: boolean } => { | |
| 142 | const h = Number(t.slice(0, 2)); | |
| 143 | return { h: h % 12 === 0 ? 12 : h % 12, m: t.slice(3, 5), pm: h >= 12 }; | |
| 144 | }; | |
| 145 | const hm = (c: { h: number; m: string }): string => (c.m === "00" ? `${c.h}` : `${c.h}:${c.m}`); | |
| 146 | ||
| 147 | /** "1–3 pm", "11 am–1 pm", "1:30–3 pm": the meridiem said once when both ends share it. */ | |
| 148 | export const timeRange = (start: string, end: string): string => { | |
| 149 | const a = clock(start); | |
| 150 | const b = clock(end); | |
| 151 | const tail = `${hm(b)} ${b.pm ? "pm" : "am"}`; | |
| 152 | return a.pm === b.pm ? `${hm(a)}–${tail}` : `${hm(a)} ${a.pm ? "pm" : "am"}–${tail}`; | |
| 153 | }; | |
| 154 | ||
| 155 | /** "Sunday, March 9, 1–3 pm", from the date and times as typed (no zone is ever applied). */ | |
| 156 | export const whenOf = (o: OpenHouse): string => { | |
| 157 | const [y, m, d] = o.date.split("-").map(Number) as [number, number, number]; | |
| 158 | const day = WEEKDAYS[new Date(Date.UTC(y, m - 1, d)).getUTCDay()]!; | |
| 159 | return `${day}, ${MONTHS[m - 1]} ${d}, ${timeRange(o.start, o.end)}`; | |
| 160 | }; | |
| 161 | ||
| 162 | /** The open house as a person would say it, in the listing's tone; null when there is none. */ | |
| 163 | export const openHouseLine = (f: Facts): string | null => { | |
| 164 | const when = (f.open_houses ?? []).map(whenOf).join(" and "); | |
| 165 | if (when === "") return null; | |
| 166 | return { | |
| 167 | classic: `Open ${when}.`, | |
| 168 | warm: `Come by the open house: ${when}.`, | |
| 169 | bold: `OPEN HOUSE: ${when}.`, | |
| 170 | }[f.tone]; | |
| 171 | }; | |
| 172 | ||
| 126 | 173 | const signature = (a: Agent): string => |
| 127 | 174 | `${a.name} · ${a.brokerage} · Lic. ${a.license}`; |
| 128 | 175 | |
| 129 | 176 | function mls(f: Facts): string { |
| 177 | const text = mlsText(f); | |
| 178 | const open = openHouseLine(f); | |
| 179 | return open === null ? text : `${text}\n${open}`; | |
| 180 | } | |
| 181 | ||
| 182 | function mlsText(f: Facts): string { | |
| 130 | 183 | const where = f.address; |
| 131 | 184 | const size = `${num(f.beds)}-bedroom, ${num(f.baths)}-bath`; |
| 132 | 185 | const sized = withArticle(size); |
| @@ -142,13 +195,14 @@ function mls(f: Facts): string { | ||
| 142 | 195 | } |
| 143 | 196 | |
| 144 | 197 | function socialShort(f: Facts, a: Agent): string { |
| 198 | const open = openHouseLine(f); | |
| 145 | 199 | const lead = { |
| 146 | 200 | classic: "Just listed", |
| 147 | 201 | warm: "Just listed, and we love it", |
| 148 | 202 | bold: "JUST LISTED", |
| 149 | 203 | }[f.tone]; |
| 150 | 204 | const first = f.features[0] === undefined ? "" : ` ${sentence(f.features[0])}`; |
| 151 | return `${lead}: ${street(f.address)} · ${num(f.beds)} bd · ${num(f.baths)} ba · ${count(f.sqft)} sq ft · ${money(f.price)}.${first} ${a.name}, ${a.brokerage}. #JustListed #NewListing`; | |
| 205 | return `${lead}: ${street(f.address)} · ${num(f.beds)} bd · ${num(f.baths)} ba · ${count(f.sqft)} sq ft · ${money(f.price)}.${first}${open === null ? "" : ` ${open}`} ${a.name}, ${a.brokerage}. #JustListed #NewListing`; | |
| 152 | 206 | } |
| 153 | 207 | |
| 154 | 208 | function socialMedium(f: Facts, a: Agent): string { |
| @@ -166,10 +220,12 @@ function socialMedium(f: Facts, a: Agent): string { | ||
| 166 | 220 | warm: "I would love to show you around. Send me a message to set up a visit.", |
| 167 | 221 | bold: "Showings are filling up. Message me today.", |
| 168 | 222 | }[f.tone]; |
| 169 | return `${open}\n\n${plural(f.beds, "bedroom")} · ${plural(f.baths, "bath")} · ${count(f.sqft)} sq ft · built ${f.year}\n\n${top}\n\n${close}\n${signature(a)} · ${a.phone}\n#JustListed #HomeForSale`; | |
| 223 | const when = openHouseLine(f); | |
| 224 | return `${open}\n\n${plural(f.beds, "bedroom")} · ${plural(f.baths, "bath")} · ${count(f.sqft)} sq ft · built ${f.year}\n\n${top}\n\n${when === null ? "" : `${when}\n`}${close}\n${signature(a)} · ${a.phone}\n#JustListed #HomeForSale`; | |
| 170 | 225 | } |
| 171 | 226 | |
| 172 | 227 | function socialCarousel(f: Facts, a: Agent): string { |
| 228 | const open = openHouseLine(f); | |
| 173 | 229 | const slides = [ |
| 174 | 230 | { |
| 175 | 231 | classic: `Just listed: ${street(f.address)}`, |
| @@ -177,6 +233,7 @@ function socialCarousel(f: Facts, a: Agent): string { | ||
| 177 | 233 | bold: `${street(f.address)}. Just listed.`, |
| 178 | 234 | }[f.tone], |
| 179 | 235 | `${money(f.price)} · ${num(f.beds)} bd · ${num(f.baths)} ba · ${count(f.sqft)} sq ft`, |
| 236 | ...(open === null ? [] : [open]), | |
| 180 | 237 | ...f.features.slice(0, 5).map(bare), |
| 181 | 238 | `Built ${f.year}${lotPhrase(f, " on ")}`, |
| 182 | 239 | `Showings with ${a.name}, ${a.brokerage} · ${a.phone} · Lic. ${a.license}`, |
| @@ -199,7 +256,8 @@ function flyer(f: Facts): string { | ||
| 199 | 256 | warm: "Come and see it for yourself.", |
| 200 | 257 | bold: "See it before it is gone.", |
| 201 | 258 | }[f.tone]; |
| 202 | return `${headline}\n\n${plural(f.beds, "bedroom")} and ${plural(f.baths, "bath")} across ${count(f.sqft)} square feet. ${body} ${close}`; | |
| 259 | const open = openHouseLine(f); | |
| 260 | return `${headline}\n\n${plural(f.beds, "bedroom")} and ${plural(f.baths, "bath")} across ${count(f.sqft)} square feet. ${body} ${close}${open === null ? "" : `\n\n${open}`}`; | |
| 203 | 261 | } |
| 204 | 262 | |
| 205 | 263 | function email(f: Facts, a: Agent): string { |
| @@ -220,7 +278,8 @@ function email(f: Facts, a: Agent): string { | ||
| 220 | 278 | warm: "If you or someone you know would like to see it, just reply to this email and I will set up a time.", |
| 221 | 279 | bold: "Want in? Reply now and I will get you on the showing schedule.", |
| 222 | 280 | }[f.tone]; |
| 223 | return `Subject: ${subject}\n\n${open}\n\nThe facts: ${plural(f.beds, "bedroom")}, ${plural(f.baths, "bath")}, ${count(f.sqft)} square feet${lotPhrase(f, " on ")}, built in ${f.year}.\n\nHighlights:\n${list}\n\n${close}\n\n${a.name}\n${a.brokerage} · License ${a.license}\n${a.phone} · ${a.email}`; | |
| 281 | const when = openHouseLine(f); | |
| 282 | return `Subject: ${subject}\n\n${when === null ? "" : `${when}\n\n`}${open}\n\nThe facts: ${plural(f.beds, "bedroom")}, ${plural(f.baths, "bath")}, ${count(f.sqft)} square feet${lotPhrase(f, " on ")}, built in ${f.year}.\n\nHighlights:\n${list}\n\n${close}\n\n${a.name}\n${a.brokerage} · License ${a.license}\n${a.phone} · ${a.email}`; | |
| 224 | 283 | } |
| 225 | 284 | |
| 226 | 285 | /** All five assets (six texts: social is three posts), for one listing. */ |
| @@ -239,8 +298,16 @@ export function generate(listing: { | ||
| 239 | 298 | }; |
| 240 | 299 | } |
| 241 | 300 | |
| 242 | /** The flyer's copy, split back into what the page lays out. */ | |
| 243 | export function flyerParts(text: string): { headline: string; body: string } { | |
| 244 | const [headline = "", ...rest] = text.split(/\n\s*\n/); | |
| 245 | return { headline: headline.trim(), body: rest.join("\n\n").trim() }; | |
| 301 | /** | |
| 302 | * The flyer's copy, split back into what the page lays out. The open house | |
| 303 | * line (`openLine`, as generated) closes the copy and is lifted out for the | |
| 304 | * band under the price; words a person has edited it out of stay as written. | |
| 305 | */ | |
| 306 | export function flyerParts( | |
| 307 | text: string, | |
| 308 | openLine: string | null = null, | |
| 309 | ): { headline: string; body: string; open: string } { | |
| 310 | const [headline = "", ...rest] = text.split(/\n\s*\n/).map((p) => p.trim()); | |
| 311 | const open = openLine !== null && rest.at(-1) === openLine ? rest.pop()! : ""; | |
| 312 | return { headline, body: rest.join("\n\n").trim(), open }; | |
| 246 | 313 | } |
src/listing.ts +61
| @@ -13,6 +13,7 @@ import { | ||
| 13 | 13 | type Agent, |
| 14 | 14 | type Facts, |
| 15 | 15 | type Kind, |
| 16 | type OpenHouse, | |
| 16 | 17 | type Tone, |
| 17 | 18 | } from "./generate.ts"; |
| 18 | 19 | |
| @@ -23,9 +24,11 @@ export type Palette = (typeof PALETTES)[number]; | ||
| 23 | 24 | export const FEATURES_MIN = 5; |
| 24 | 25 | export const FEATURES_MAX = 10; |
| 25 | 26 | export const ASSET_MAX = 5000; |
| 27 | export const OPEN_HOUSES_MAX = 2; | |
| 26 | 28 | |
| 27 | 29 | /** What a person sends to launch a listing, validated. */ |
| 28 | 30 | export interface ListingInput extends Facts { |
| 31 | readonly open_houses: readonly OpenHouse[]; | |
| 29 | 32 | readonly palette: Palette; |
| 30 | 33 | readonly agent_name: string; |
| 31 | 34 | readonly brokerage: string; |
| @@ -98,6 +101,7 @@ export interface ListingRow { | ||
| 98 | 101 | readonly features: string; |
| 99 | 102 | readonly tone: Tone; |
| 100 | 103 | readonly palette: Palette; |
| 104 | readonly open_houses: string; | |
| 101 | 105 | readonly agent_name: string; |
| 102 | 106 | readonly brokerage: string; |
| 103 | 107 | readonly license: string; |
| @@ -112,6 +116,7 @@ export interface ListingRow { | ||
| 112 | 116 | export const fromRow = (row: ListingRow): Listing => ({ |
| 113 | 117 | ...row, |
| 114 | 118 | features: JSON.parse(row.features) as string[], |
| 119 | open_houses: JSON.parse(row.open_houses) as OpenHouse[], | |
| 115 | 120 | photos: JSON.parse(row.photos) as string[], |
| 116 | 121 | }); |
| 117 | 122 | |
| @@ -182,6 +187,58 @@ export const featureLines = (v: unknown): string[] => | ||
| 182 | 187 | .map((l) => line(l).replace(/^[-•*]\s*/, "")) |
| 183 | 188 | .filter((l) => l !== ""); |
| 184 | 189 | |
| 190 | /** The form posts `open1_date`, `open1_start`, `open1_end` (and `open2_…`); a script sends `open_houses: [{ date, start, end }]`. */ | |
| 191 | const OPEN_FIELD = /^open[1-9]_(date|start|end)$/; | |
| 192 | export const touchesOpenHouses = (raw: Readonly<Record<string, unknown>>): boolean => | |
| 193 | raw.open_houses !== undefined || Object.keys(raw).some((k) => OPEN_FIELD.test(k)); | |
| 194 | ||
| 195 | const openHousesRaw = (raw: Readonly<Record<string, unknown>>): unknown[] | undefined => { | |
| 196 | if (raw.open_houses !== undefined) | |
| 197 | return raw.open_houses === null || (typeof raw.open_houses === "string" && raw.open_houses.trim() === "") | |
| 198 | ? [] | |
| 199 | : Array.isArray(raw.open_houses) | |
| 200 | ? raw.open_houses | |
| 201 | : undefined; | |
| 202 | return Array.from({ length: OPEN_HOUSES_MAX }, (_, i) => ({ | |
| 203 | date: raw[`open${i + 1}_date`], | |
| 204 | start: raw[`open${i + 1}_start`], | |
| 205 | end: raw[`open${i + 1}_end`], | |
| 206 | })); | |
| 207 | }; | |
| 208 | ||
| 209 | const realDate = (t: string): boolean => { | |
| 210 | const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(t); | |
| 211 | if (m === null) return false; | |
| 212 | const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])]; | |
| 213 | const at = new Date(Date.UTC(y, mo - 1, d)); | |
| 214 | return y >= 1900 && at.getUTCFullYear() === y && at.getUTCMonth() === mo - 1 && at.getUTCDate() === d; | |
| 215 | }; | |
| 216 | const realTime = (t: string): boolean => /^([01]\d|2[0-3]):[0-5]\d$/.test(t); | |
| 217 | ||
| 218 | /** The open houses, checked: blank rows dropped, a real date, an end after the start, at most two, in date order. */ | |
| 219 | export function openHouses(raw: Readonly<Record<string, unknown>>): Checked<OpenHouse[]> { | |
| 220 | const rows = openHousesRaw(raw); | |
| 221 | const bad = (message: string): Checked<OpenHouse[]> => ({ ok: false, message }); | |
| 222 | if (rows === undefined) return bad("Open houses is a list of dates and times."); | |
| 223 | const filled = rows.filter((r) => typeof r !== "object" || r === null || Object.values(r).some((v) => line(v) !== "")); | |
| 224 | if (filled.length > OPEN_HOUSES_MAX) return bad(`At most ${OPEN_HOUSES_MAX} open houses.`); | |
| 225 | const found: OpenHouse[] = []; | |
| 226 | for (const r of filled) { | |
| 227 | const o = typeof r === "object" && r !== null ? (r as Record<string, unknown>) : {}; | |
| 228 | const date = line(o.date); | |
| 229 | const start = line(o.start); | |
| 230 | const end = line(o.end); | |
| 231 | if (date === "" && start === "" && end === "") continue; | |
| 232 | if (!realDate(date)) return bad("The open house date is a real date, like 2027-03-09."); | |
outdated
Every row is walked before the max-two check; refuse a list longer than OPEN_HOUSES_MAX up front so a large array costs nothing. |
||
| 233 | if (!realTime(start) || !realTime(end)) | |
| 234 | return bad("The open house start and end are times like 13:00."); | |
| 235 | if (end <= start) return bad("The open house ends after it starts."); | |
| 236 | found.push({ date, start, end }); | |
| 237 | } | |
| 238 | found.sort((a, b) => `${a.date}${a.start}`.localeCompare(`${b.date}${b.start}`)); | |
| 239 | return { ok: true, value: found }; | |
| 240 | } | |
| 241 | ||
| 185 | 242 | /** |
| 186 | 243 | * Is this a listing? Every field from the wire, checked and normalised, or |
| 187 | 244 | * the first thing wrong with it in words a person can act on. |
| @@ -228,6 +285,9 @@ export function validate( | ||
| 228 | 285 | const palette = raw.palette ?? "navy"; |
| 229 | 286 | if (!isPalette(palette)) return bad(`The colour is one of ${PALETTES.join(", ")}.`); |
| 230 | 287 | |
| 288 | const open = openHouses(raw); | |
| 289 | if (!open.ok) return bad(open.message); | |
| 290 | ||
| 231 | 291 | const agent_name = text("agent_name", 2, 80); |
| 232 | 292 | if (agent_name === undefined) return bad("Your name is 2 to 80 characters."); |
| 233 | 293 | const brokerage = text("brokerage", 2, 100); |
| @@ -256,6 +316,7 @@ export function validate( | ||
| 256 | 316 | features, |
| 257 | 317 | tone, |
| 258 | 318 | palette, |
| 319 | open_houses: open.value, | |
| 259 | 320 | agent_name, |
| 260 | 321 | brokerage, |
| 261 | 322 | license, |
src/mcp.ts +15 −3
| @@ -40,6 +40,17 @@ interface Tool { | ||
| 40 | 40 | ) => Result<unknown>; |
| 41 | 41 | } |
| 42 | 42 | |
| 43 | const OPEN_HOUSES = { | |
| 44 | type: "array", | |
| 45 | maxItems: 2, | |
| 46 | description: "Up to two open houses in local time as typed: { date: YYYY-MM-DD, start: HH:MM, end: HH:MM }.", | |
| 47 | items: { | |
| 48 | type: "object", | |
| 49 | properties: { date: { type: "string" }, start: { type: "string" }, end: { type: "string" } }, | |
| 50 | required: ["date", "start", "end"], | |
| 51 | }, | |
| 52 | }; | |
| 53 | ||
| 43 | 54 | export const tools: readonly Tool[] = [ |
| 44 | 55 | { |
| 45 | 56 | name: "list_notes", |
| @@ -104,7 +115,7 @@ export const tools: readonly Tool[] = [ | ||
| 104 | 115 | { |
| 105 | 116 | name: "launch_listing", |
| 106 | 117 | description: |
| 107 | "Launch a listing from its facts and the agent's brand (no photos here): address, beds, baths, sqft, lot, year, price, five to ten features, tone, palette, agent_name, brokerage, license, agent_phone, agent_email.", | |
| 118 | "Launch a listing from its facts and the agent's brand (no photos here): address, beds, baths, sqft, lot, year, price, five to ten features, tone, palette, open_houses (optional, up to two), agent_name, brokerage, license, agent_phone, agent_email.", | |
| 108 | 119 | inputSchema: { |
| 109 | 120 | type: "object", |
| 110 | 121 | properties: { |
| @@ -123,6 +134,7 @@ export const tools: readonly Tool[] = [ | ||
| 123 | 134 | license: { type: "string" }, |
| 124 | 135 | agent_phone: { type: "string" }, |
| 125 | 136 | agent_email: { type: "string" }, |
| 137 | open_houses: OPEN_HOUSES, | |
| 126 | 138 | }, |
| 127 | 139 | required: ["address", "beds", "baths", "sqft", "lot", "year", "price", "features", "agent_name", "brokerage", "license", "agent_phone", "agent_email"], |
| 128 | 140 | }, |
| @@ -209,7 +221,7 @@ export const tools: readonly Tool[] = [ | ||
| 209 | 221 | { |
| 210 | 222 | name: "update_facts", |
| 211 | 223 | description: |
| 212 | "Change a listing's facts (price, square feet, features, brand...): pass only the fields that change. Every asset is written again in its tone, replacing edits and clearing every approval. Only the listing's agent or an admin can.", | |
| 224 | "Change a listing's facts (price, square feet, features, open houses, brand...): pass only the fields that change. Every asset is written again in its tone, replacing edits and clearing every approval. Only the listing's agent or an admin can.", | |
| 213 | 225 | inputSchema: { |
| 214 | 226 | type: "object", |
| 215 | 227 | properties: { |
| @@ -217,7 +229,7 @@ export const tools: readonly Tool[] = [ | ||
| 217 | 229 | facts: { |
| 218 | 230 | type: "object", |
| 219 | 231 | description: |
| 220 | "Any of address, beds, baths, sqft, lot, year, price, features, tone, palette, agent_name, brokerage, license, agent_phone, agent_email.", | |
| 232 | "Any of address, beds, baths, sqft, lot, year, price, features, tone, palette, open_houses (a list of up to two { date: YYYY-MM-DD, start: HH:MM, end: HH:MM }; [] clears), agent_name, brokerage, license, agent_phone, agent_email.", | |
| 221 | 233 | }, |
| 222 | 234 | }, |
| 223 | 235 | required: ["id", "facts"], |
src/openhouse.test.ts added +187
| @@ -0,0 +1,187 @@ | ||
| 1 | /** | |
| 2 | * The open house: validated at the boundary, written into every asset in | |
| 3 | * every tone, rewritten (and the approvals cleared) when it changes, and | |
| 4 | * reachable from the page, the API, the MCP and the terminal. | |
| 5 | */ | |
| 6 | import { expect, test } from "bun:test"; | |
| 7 | import { mkdtempSync } from "node:fs"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { approveAsset, getListing, launchListing, updateFacts } from "./actions.ts"; | |
| 11 | import { openDb } from "./db.ts"; | |
| 12 | import { check } from "./fairhousing.ts"; | |
| 13 | import { generate, openHouseLine, timeRange, TONES, KINDS, flyerParts, type OpenHouse } from "./generate.ts"; | |
| 14 | import { agentOf, validate } from "./listing.ts"; | |
| 15 | import { rpc } from "./mcp.ts"; | |
| 16 | import { SAMPLE } from "./sample.ts"; | |
| 17 | import { session } from "./tty.ts"; | |
| 18 | import type { Who } from "./who.ts"; | |
| 19 | ||
| 20 | const sunday: OpenHouse = { date: "2027-03-07", start: "13:00", end: "15:00" }; | |
| 21 | const saturday: OpenHouse = { date: "2027-03-06", start: "11:00", end: "13:30" }; | |
| 22 | const me: Who = { role: "user", id: "u1", name: "me" }; | |
| 23 | const plain = (s: string): string => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""); | |
| 24 | ||
| 25 | test("times read like a person said them", () => { | |
| 26 | expect(timeRange("13:00", "15:00")).toBe("1–3 pm"); | |
| 27 | expect(timeRange("11:00", "13:00")).toBe("11 am–1 pm"); | |
| 28 | expect(timeRange("13:30", "15:00")).toBe("1:30–3 pm"); | |
| 29 | expect(timeRange("09:00", "11:30")).toBe("9–11:30 am"); | |
| 30 | expect(timeRange("00:00", "12:00")).toBe("12 am–12 pm"); | |
| 31 | expect(openHouseLine({ ...SAMPLE, tone: "classic", open_houses: [sunday] })).toBe("Open Sunday, March 7, 1–3 pm."); | |
| 32 | expect(openHouseLine({ ...SAMPLE, tone: "classic", open_houses: [saturday, sunday] })).toBe( | |
| 33 | "Open Saturday, March 6, 11 am–1:30 pm and Sunday, March 7, 1–3 pm.", | |
| 34 | ); | |
| 35 | expect(openHouseLine({ ...SAMPLE, open_houses: [] })).toBeNull(); | |
| 36 | }); | |
| 37 | ||
| 38 | test("validation: a real date, an end after the start, at most two", () => { | |
| 39 | const ok = validate({ ...SAMPLE, open_houses: [sunday] }); | |
| 40 | expect(ok.ok && ok.value.open_houses).toEqual([sunday]); | |
| 41 | // The form's fields mean the same, and a blank row is no open house. | |
| 42 | const form = validate({ ...SAMPLE, open_houses: undefined, open1_date: "2027-03-07", open1_start: "13:00", open1_end: "15:00", open2_date: "", open2_start: "", open2_end: "" }); | |
| 43 | expect(form.ok && form.value.open_houses).toEqual([sunday]); | |
| 44 | // In date order, whichever order they came. | |
| 45 | const two = validate({ ...SAMPLE, open_houses: [sunday, saturday] }); | |
| 46 | expect(two.ok && two.value.open_houses).toEqual([saturday, sunday]); | |
| 47 | const refused: [unknown, string][] = [ | |
| 48 | [[{ ...sunday, date: "2027-02-30" }], "real date"], | |
| 49 | [[{ ...sunday, date: "next Sunday" }], "real date"], | |
| 50 | [[{ ...sunday, start: "25:00" }], "times"], | |
| 51 | [[{ ...sunday, end: "" }], "times"], | |
| 52 | [[{ ...sunday, end: "13:00" }], "ends after"], | |
| 53 | [[{ ...sunday, end: "12:00" }], "ends after"], | |
| 54 | [[sunday, saturday, { ...sunday, date: "2027-03-08" }], "At most 2"], | |
| 55 | ["Sunday", "list"], | |
| 56 | ]; | |
| 57 | for (const [open_houses, says] of refused) { | |
| 58 | const r = validate({ ...SAMPLE, open_houses }); | |
| 59 | expect([says, r.ok ? "accepted" : r.message.includes(says)]).toEqual([says, true]); | |
| 60 | } | |
| 61 | }); | |
| 62 | ||
| 63 | test("every asset carries the line when set, in every tone, and none when cleared", () => { | |
| 64 | for (const tone of TONES) { | |
| 65 | const facts = { ...SAMPLE, tone, open_houses: [sunday] }; | |
| 66 | const line = openHouseLine(facts)!; | |
| 67 | const words = generate({ facts, agent: agentOf(SAMPLE) }); | |
| 68 | for (const kind of KINDS) expect([tone, kind, words[kind].includes(line)]).toEqual([tone, kind, true]); | |
| 69 | expect(words.mls.split("\n").at(-1)).toBe(line); | |
| 70 | expect(words.email.split("\n\n")[1]).toBe(line); | |
| 71 | expect(flyerParts(words.flyer, line).open).toBe(line); | |
| 72 | expect(flyerParts(words.flyer, line).body).not.toContain("Sunday"); | |
| 73 | for (const kind of KINDS) expect(check(words[kind]).clear).toBe(true); | |
| 74 | const none = generate({ facts: { ...facts, open_houses: [] }, agent: agentOf(SAMPLE) }); | |
| 75 | for (const kind of KINDS) expect([tone, kind, /open|Sunday/i.test(none[kind])]).toEqual([tone, kind, false]); | |
| 76 | } | |
| 77 | }); | |
| 78 | ||
| 79 | test("adding, changing and clearing it writes the assets again and clears approval", () => { | |
| 80 | const db = openDb(mkdtempSync(join(tmpdir(), "app-open-"))); | |
| 81 | const made = launchListing(db, me, { fields: { ...SAMPLE, tone: "classic" } }); | |
| 82 | if (!made.ok) throw new Error(made.message); | |
| 83 | const id = made.value.id; | |
| 84 | const text = (kind: string): string => { | |
| 85 | const l = getListing(db, me, { id }); | |
| 86 | if (!l.ok) throw new Error(l.message); | |
| 87 | return l.value.assets.find((a) => a.kind === kind)!.text; | |
| 88 | }; | |
| 89 | const approve = () => { | |
| 90 | const l = getListing(db, me, { id }); | |
| 91 | if (!l.ok) throw new Error(l.message); | |
| 92 | for (const a of l.value.assets) approveAsset(db, me, { id, kind: a.kind, door: "page", seen: a.updated_at }); | |
| 93 | const again = getListing(db, me, { id }); | |
| 94 | return again.ok ? again.value.assets.filter((a) => a.approval !== null).length : -1; | |
| 95 | }; | |
| 96 | expect(text("flyer")).not.toContain("Open "); | |
| 97 | expect(approve()).toBe(6); | |
| 98 | ||
| 99 | const set = updateFacts(db, me, { id, fields: { open_houses: [sunday] } }); | |
| 100 | expect(set).toMatchObject({ ok: true, value: { open_houses: [sunday] } }); | |
| 101 | expect(text("flyer")).toContain("Open Sunday, March 7, 1–3 pm."); | |
| 102 | expect(text("email")).toContain("Open Sunday, March 7, 1–3 pm."); | |
| 103 | if (set.ok) expect(set.value.assets.every((a) => a.approval === null)).toBe(true); | |
| 104 | ||
| 105 | // A change to something else keeps it. | |
| 106 | expect(approve()).toBe(6); | |
| 107 | expect(updateFacts(db, me, { id, fields: { price: 650000 } })).toMatchObject({ ok: true, value: { open_houses: [sunday] } }); | |
| 108 | ||
| 109 | const moved = updateFacts(db, me, { id, fields: { open_houses: [{ ...sunday, date: "2027-03-14" }] } }); | |
| 110 | expect(moved.ok).toBe(true); | |
| 111 | expect(text("mls")).toContain("Sunday, March 14, 1–3 pm"); | |
| 112 | expect(text("mls")).not.toContain("March 7"); | |
| 113 | ||
| 114 | expect(approve()).toBe(6); | |
| 115 | const cleared = updateFacts(db, me, { id, fields: { open_houses: [] } }); | |
| 116 | if (!cleared.ok) throw new Error(cleared.message); | |
| 117 | expect(cleared.value.open_houses).toEqual([]); | |
| 118 | expect(cleared.value.assets.every((a) => a.approval === null && !/open house|Open /i.test(a.text))).toBe(true); | |
| 119 | ||
| 120 | // The form's fields, blank, clear it too. | |
| 121 | updateFacts(db, me, { id, fields: { open_houses: [sunday] } }); | |
| 122 | const blank = updateFacts(db, me, { id, fields: { open1_date: "", open1_start: "", open1_end: "" } }); | |
| 123 | expect(blank).toMatchObject({ ok: true, value: { open_houses: [] } }); | |
| 124 | ||
| 125 | // A bad one is refused and changes nothing. | |
| 126 | const before = JSON.stringify(getListing(db, me, { id })); | |
| 127 | expect(updateFacts(db, me, { id, fields: { open_houses: [{ ...sunday, end: "09:00" }] } })).toMatchObject({ ok: false, error: "invalid" }); | |
| 128 | expect(JSON.stringify(getListing(db, me, { id }))).toBe(before); | |
| 129 | }); | |
| 130 | ||
| 131 | test("MCP and the terminal set it through the same action", () => { | |
| 132 | const db = openDb(mkdtempSync(join(tmpdir(), "app-open-doors-"))); | |
| 133 | const made = launchListing(db, me, { fields: { ...SAMPLE, open_houses: [saturday] } }); | |
| 134 | if (!made.ok) throw new Error(made.message); | |
| 135 | const id = made.value.id; | |
| 136 | expect(made.value.open_houses).toEqual([saturday]); | |
| 137 | ||
| 138 | const call = rpc(db, me, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "update_facts", arguments: { id, facts: { open_houses: [sunday] } } } }).body as { result: { content: { text: string }[] } }; | |
| 139 | expect(call.result.content[0]!.text).toContain("2027-03-07"); | |
| 140 | ||
| 141 | const tty = session(db, me); | |
| 142 | tty.press("\r"); | |
| 143 | expect(plain(tty.screen())).toContain("Sunday, March 7, 1–3 pm"); | |
| 144 | tty.press("o"); | |
| 145 | expect(plain(tty.screen())).toContain("2027-03-07 13:00-15:00"); | |
| 146 | for (let i = 0; i < 60; i++) tty.press("\x7f"); | |
| 147 | for (const key of "2027-03-13 10:00-12:00; 2027-03-14 14:00-16:00") tty.press(key); | |
| 148 | tty.press("\r"); | |
| 149 | expect(plain(tty.screen())).toContain("Open house saved"); | |
| 150 | const now = getListing(db, me, { id }); | |
| 151 | expect(now.ok && now.value.open_houses.map((o) => o.date)).toEqual(["2027-03-13", "2027-03-14"]); | |
| 152 | tty.press("o"); | |
| 153 | for (let i = 0; i < 80; i++) tty.press("\x7f"); | |
| 154 | for (const key of "2027-02-30 10:00-12:00") tty.press(key); | |
| 155 | tty.press("\r"); | |
| 156 | expect(plain(tty.screen())).toContain("real date"); | |
| 157 | tty.press("o"); | |
| 158 | for (let i = 0; i < 80; i++) tty.press("\x7f"); | |
| 159 | tty.press("\r"); | |
| 160 | const gone = getListing(db, me, { id }); | |
| 161 | expect(gone.ok && gone.value.open_houses).toEqual([]); | |
| 162 | }); | |
| 163 | ||
| 164 | test("PATCH /api/listings/:id sets and clears it, and the API describes it", async () => { | |
| 165 | const { serve } = await import("./server.ts"); | |
| 166 | const { sign } = await import("./who.ts"); | |
| 167 | const KEY = "a-key-only-the-forge-holds"; | |
| 168 | const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-open-api-")), identityKey: KEY }); | |
| 169 | try { | |
| 170 | const base = `http://${server.hostname}:${server.port}`; | |
| 171 | const identity = `open-api|open-api-agent|user|${Date.now() + 600_000}`; | |
| 172 | const headers = { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY), "content-type": "application/json" }; | |
| 173 | const launch = await fetch(`${base}/api/listings`, { method: "POST", headers, body: JSON.stringify({ ...SAMPLE, open_houses: [sunday] }) }); | |
| 174 | expect(launch.status).toBe(201); | |
| 175 | const made = (await launch.json()) as { id: number; open_houses: unknown[]; assets: { text: string }[] }; | |
| 176 | expect(made.open_houses).toEqual([sunday]); | |
| 177 | expect(made.assets.every((a) => a.text.includes("Sunday, March 7, 1–3 pm"))).toBe(true); | |
| 178 | const patch = (facts: unknown) => fetch(`${base}/api/listings/${made.id}`, { method: "PATCH", headers, body: JSON.stringify({ facts }) }); | |
| 179 | expect((await patch({ open_houses: [{ ...sunday, end: "12:00" }] })).status).toBe(422); | |
| 180 | const cleared = (await (await patch({ open_houses: [] })).json()) as { open_houses: unknown[]; assets: { text: string }[] }; | |
| 181 | expect(cleared.open_houses).toEqual([]); | |
| 182 | expect(cleared.assets.some((a) => a.text.includes("Sunday"))).toBe(false); | |
| 183 | expect(await (await fetch(`${base}/openapi.json`)).text()).toContain("open_houses"); | |
| 184 | } finally { | |
| 185 | server.stop(true); | |
| 186 | } | |
| 187 | }); |
src/pages/home.ts +15
| @@ -51,6 +51,13 @@ export function draftOf(listing: Listing, brandOnly: boolean): Draft { | ||
| 51 | 51 | year: String(listing.year), |
| 52 | 52 | price: String(listing.price), |
| 53 | 53 | features: listing.features.join("\n"), |
| 54 | ...Object.fromEntries( | |
| 55 | listing.open_houses.flatMap((o, i) => [ | |
| 56 | [`open${i + 1}_date`, o.date], | |
| 57 | [`open${i + 1}_start`, o.start], | |
| 58 | [`open${i + 1}_end`, o.end], | |
| 59 | ]), | |
| 60 | ), | |
| 54 | 61 | }; |
| 55 | 62 | } |
| 56 | 63 | |
| @@ -127,6 +134,14 @@ export function form(who: Who, draft: Draft, editing?: Editing): string { | ||
| 127 | 134 | <textarea class="input textarea" name="features" rows="7" required placeholder="${ph(SAMPLE.features.slice(0, 5).join("\n"))}">${v(draft, "features")}</textarea></label> |
| 128 | 135 | ${editing === undefined ? photos : ""} |
| 129 | 136 | </fieldset> |
| 137 | <fieldset> | |
| 138 | <legend>Open house <small class="muted">optional; up to two, on the flyer, the email and every post</small></legend> | |
| 139 | ${[1, 2].map((n) => `<div class="facts-grid open-grid"> | |
| 140 | ${field(draft, `open${n}_date`, n === 1 ? "Date" : "Second date", 'type="date"', "")} | |
| 141 | ${field(draft, `open${n}_start`, "Starts", 'type="time"', "")} | |
|
||
| 142 | ${field(draft, `open${n}_end`, "Ends", 'type="time"', "")} | |
| 143 | </div>`).join("")} | |
| 144 | </fieldset> | |
| 130 | 145 | <fieldset> |
| 131 | 146 | <legend>Tone</legend> |
| 132 | 147 | <div class="choices">${TONES.map( |
src/pages/listing.ts +5 −1
| @@ -9,6 +9,7 @@ import { | ||
| 9 | 9 | factItems, |
| 10 | 10 | factsLine, |
| 11 | 11 | flyerParts, |
| 12 | openHouseLine, | |
| 12 | 13 | money, |
| 13 | 14 | TONES, |
| 14 | 15 | } from "../generate.ts"; |
| @@ -148,6 +149,7 @@ export function listingPage(input: { | ||
| 148 | 149 | l.assets |
| 149 | 150 | .filter((a) => kinds.includes(a.kind)) |
| 150 | 151 | .reduce((s, a) => s + a.check.findings.length, 0); |
| 152 | const whenLine = openHouseLine(l); | |
| 151 | 153 | const tone = editable |
| 152 | 154 | ? `<form class="row tone" method="post" action="/listings/${l.id}/tone"> |
| 153 | 155 | <label class="sr" for="tone">Tone</label> |
| @@ -163,6 +165,7 @@ export function listingPage(input: { | ||
| 163 | 165 | <h2>${escape(l.address)}</h2> |
| 164 | 166 | <p class="listing-price">${money(l.price)}</p> |
| 165 | 167 | <p class="muted">${escape(factsLine(l))}</p> |
outdated
|
||
| 168 | ${whenLine === null ? "" : `<p class="listing-open">${escape(whenLine)}</p>`} | |
| 166 | 169 | <p class="muted"><small>${escape(l.agent_name)} · ${escape(l.brokerage)} · updated ${ago(l.updated_at)}</small></p> |
| 167 | 170 | </div> |
| 168 | 171 | <div class="listing-tools"> |
| @@ -207,7 +210,7 @@ export function listingPage(input: { | ||
| 207 | 210 | |
| 208 | 211 | /** The flyer: the showpiece, a letter-size page in the agent's colours. */ |
| 209 | 212 | export function flyer(l: Launched): string { |
| 210 | const copy = flyerParts(l.assets.find((a) => a.kind === "flyer")?.text ?? ""); | |
| 213 | const copy = flyerParts(l.assets.find((a) => a.kind === "flyer")?.text ?? "", openHouseLine(l)); | |
| 211 | 214 | const [hero, ...more] = l.photos; |
| 212 | 215 | return `<article class="flyer palette-${l.palette}"> |
| 213 | 216 | ${draftBand(l, "flyer")} |
| @@ -222,6 +225,7 @@ export function flyer(l: Launched): string { | ||
| 222 | 225 | </div> |
| 223 | 226 | <p class="flyer-price brand">${money(l.price)}</p> |
| 224 | 227 | </header> |
| 228 | ${copy.open === "" ? "" : `<p class="flyer-open brand-fill">${escape(copy.open)}</p>`} | |
| 225 | 229 | <ul class="flyer-facts brand-fill"> |
| 226 | 230 | ${factItems(l).map((i) => `<li><b>${escape(i.value)}</b> ${i.label}</li>`).join("\n ")} |
| 227 | 231 | </ul> |
src/routes.ts +21 −2
| @@ -162,6 +162,20 @@ const CHECK: Schema = { | ||
| 162 | 162 | }, |
| 163 | 163 | required: ["clear", "findings"], |
| 164 | 164 | }; |
| 165 | const OPEN_HOUSES = { | |
| 166 | type: "array", | |
| 167 | maxItems: 2, | |
| 168 | description: "Up to two open houses, in local time as typed: a real date (YYYY-MM-DD), start and end as HH:MM with the end after the start. An empty list clears them.", | |
| 169 | items: { | |
| 170 | type: "object", | |
| 171 | properties: { | |
| 172 | date: { type: "string", format: "date" }, | |
| 173 | start: { type: "string", pattern: "^([01]\\d|2[0-3]):[0-5]\\d$" }, | |
| 174 | end: { type: "string", pattern: "^([01]\\d|2[0-3]):[0-5]\\d$" }, | |
| 175 | }, | |
| 176 | required: ["date", "start", "end"], | |
| 177 | }, | |
| 178 | } as const; | |
| 165 | 179 | const FACTS = { |
| 166 | 180 | address: { type: "string", minLength: 5, maxLength: 120 }, |
| 167 | 181 | beds: { type: "integer", minimum: 0, maximum: 50 }, |
| @@ -183,6 +197,7 @@ const FACTS = { | ||
| 183 | 197 | license: { type: "string", minLength: 2, maxLength: 40 }, |
| 184 | 198 | agent_phone: { type: "string", maxLength: 30 }, |
| 185 | 199 | agent_email: { type: "string", format: "email", maxLength: 120 }, |
| 200 | open_houses: OPEN_HOUSES, | |
| 186 | 201 | } as const; |
| 187 | 202 | const LISTING_FIELDS = { |
| 188 | 203 | id: { type: "integer" }, |
| @@ -256,7 +271,7 @@ export const LISTING: Schema = { | ||
| 256 | 271 | const LISTING_BODY: Schema = { |
| 257 | 272 | type: "object", |
| 258 | 273 | properties: FACTS, |
| 259 | required: Object.keys(FACTS).filter((k) => k !== "tone" && k !== "palette"), | |
| 274 | required: Object.keys(FACTS).filter((k) => k !== "tone" && k !== "palette" && k !== "open_houses"), | |
| 260 | 275 | }; |
| 261 | 276 | const LISTING_CHANGE: Schema = { |
| 262 | 277 | type: "object", |
| @@ -361,6 +376,10 @@ const textFields = (form: FormData): Record<string, string> => | ||
| 361 | 376 | [...form.entries()].flatMap(([k, v]) => (typeof v === "string" ? [[k, v]] : [])), |
| 362 | 377 | ); |
| 363 | 378 | |
| 379 | /** A new listing started from another keeps its facts, not its open house. */ | |
| 380 | const withoutOpenHouses = (draft: Draft): Draft => | |
| 381 | Object.fromEntries(Object.entries(draft).filter(([k]) => !/^open\d_/.test(k))); | |
| 382 | ||
| 364 | 383 | /** A whole number from the query, or undefined. */ |
| 365 | 384 | const whole = (value: string | null): number | undefined => |
| 366 | 385 | value !== null && /^\d+$/.test(value) ? Number(value) : undefined; |
| @@ -387,7 +406,7 @@ export const routes: readonly Route[] = [ | ||
| 387 | 406 | : listings.value.find((l) => l.author_id === (c.who as { id: string }).id); |
| 388 | 407 | const draft: Draft = |
| 389 | 408 | chosen?.ok === true |
| 390 | ? draftOf(chosen.value, false) | |
| 409 | ? withoutOpenHouses(draftOf(chosen.value, false)) | |
| 391 | 410 | : mine === undefined |
| 392 | 411 | ? {} |
| 393 | 412 | : draftOf(mine, true); |
src/sample.ts +7 −1
| @@ -28,6 +28,7 @@ export const SAMPLE: ListingInput = { | ||
| 28 | 28 | ], |
| 29 | 29 | tone: "warm", |
| 30 | 30 | palette: "forest", |
| 31 | open_houses: [], | |
| 31 | 32 | agent_name: "Morgan Reyes", |
| 32 | 33 | brokerage: "Harbor & Pine Realty", |
| 33 | 34 | license: "OR 201255501", |
| @@ -35,6 +36,9 @@ export const SAMPLE: ListingInput = { | ||
| 35 | 36 | agent_email: "[email protected]", |
| 36 | 37 | }; |
| 37 | 38 | |
| 39 | /** The one open house a sample plants, so the meeting sees it on the flyer and in the email. */ | |
| 40 | export const SAMPLE_OPEN_HOUSE = { date: "2027-03-07", start: "13:00", end: "15:00" }; | |
| 41 | ||
| 38 | 42 | export const SAMPLE_PHOTOS = ["sample-front.svg", "sample-garden.svg"]; |
| 39 | 43 | export const SAMPLE_HEADSHOT = "sample-agent.svg"; |
| 40 | 44 | |
| @@ -58,6 +62,7 @@ export const SAMPLE_CONDO: ListingInput = { | ||
| 58 | 62 | ], |
| 59 | 63 | tone: "bold", |
| 60 | 64 | palette: "navy", |
| 65 | open_houses: [], | |
| 61 | 66 | agent_name: "Jordan Whitfield", |
| 62 | 67 | brokerage: "Cascade Row Properties", |
| 63 | 68 | license: "WA 700123456", |
| @@ -88,6 +93,7 @@ export const SAMPLE_HOUSE: ListingInput = { | ||
| 88 | 93 | ], |
| 89 | 94 | tone: "classic", |
| 90 | 95 | palette: "burgundy", |
| 96 | open_houses: [], | |
| 91 | 97 | agent_name: "Casey Lindqvist", |
| 92 | 98 | brokerage: "Gem State Homes", |
| 93 | 99 | license: "ID SP98765432", |
| @@ -104,7 +110,7 @@ interface Sample { | ||
| 104 | 110 | } |
| 105 | 111 | |
| 106 | 112 | const SAMPLES: readonly Sample[] = [ |
| 107 | { name: "sample", listing: SAMPLE, photos: SAMPLE_PHOTOS, headshot: SAMPLE_HEADSHOT }, | |
| 113 | { name: "sample", listing: { ...SAMPLE, open_houses: [SAMPLE_OPEN_HOUSE] }, photos: SAMPLE_PHOTOS, headshot: SAMPLE_HEADSHOT }, | |
| 108 | 114 | { |
| 109 | 115 | name: "sample-condo", |
| 110 | 116 | listing: SAMPLE_CONDO, |
src/tty.ts +23 −6
| @@ -29,12 +29,13 @@ import { | ||
| 29 | 29 | listListings, |
| 30 | 30 | listNotes, |
| 31 | 31 | retoneListing, |
| 32 | updateFacts, | |
| 32 | 33 | useSuggestion, |
| 33 | 34 | } from "./actions.ts"; |
| 34 | 35 | import { ssh, title, where } from "./env.ts"; |
| 35 | 36 | import { approvalLabel } from "./listing.ts"; |
| 36 | 37 | import { emailParts } from "./pages/email.ts"; |
| 37 | import { factItems, money, TONES, type Kind } from "./generate.ts"; | |
| 38 | import { factItems, money, openHouseLine, TONES, type Kind, type OpenHouse } from "./generate.ts"; | |
| 38 | 39 | import type { Who } from "./who.ts"; |
| 39 | 40 | |
| 40 | 41 | const esc = "\x1b["; |
| @@ -58,6 +59,15 @@ const scrub = <T>(value: T): T => { | ||
| 58 | 59 | return value; |
| 59 | 60 | }; |
| 60 | 61 | |
| 62 | /** The open houses in the one-line box: "2027-03-09 13:00-15:00; 2027-03-10 11:00-13:00"; empty clears them. */ | |
| 63 | export const openToLine = (rows: readonly OpenHouse[]): string => | |
| 64 | rows.map((o) => `${o.date} ${o.start}-${o.end}`).join("; "); | |
| 65 | export const openFromLine = (line: string): { date: string; start: string; end: string }[] => | |
| 66 | line.split(";").map((r) => r.trim()).filter((r) => r !== "").map((r) => { | |
| 67 | const m = /^(\S+)\s+(\S+?)-(\S+)$/.exec(r); | |
| 68 | return { date: m?.[1] ?? r, start: m?.[2] ?? "", end: m?.[3] ?? "" }; | |
| 69 | }); | |
| 70 | ||
| 61 | 71 | /** In the one-line edit box, `\n` typed as two characters is a line break. */ |
| 62 | 72 | export const toLine = (text: string): string => text.replace(/\n/g, "\\n"); |
| 63 | 73 | export const fromLine = (line: string): string => line.replace(/\\n/g, "\n"); |
| @@ -69,7 +79,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 69 | 79 | let previewing = false; |
| 70 | 80 | let cursor = 0; |
| 71 | 81 | /** What the one-line input is for: a new note, or an asset's new words. */ |
| 72 | let typing: { for: "note" } | { for: "asset"; kind: Kind } | undefined; | |
| 82 | let typing: { for: "note" } | { for: "asset"; kind: Kind } | { for: "open" } | undefined; | |
| 73 | 83 | let typed = ""; |
| 74 | 84 | let said = ""; |
| 75 | 85 | |
| @@ -105,7 +115,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 105 | 115 | ]; |
| 106 | 116 | } |
| 107 | 117 | const signed = l.assets.filter((a) => a.approval !== null).length; |
| 108 | const head = [bold(`${l.address} · ${money(l.price)}`), dim(`${l.tone} tone · ${approvalLabel(signed, l.assets.length)}`), ""]; | |
| 118 | const whenLine = openHouseLine(l); | |
| 119 | const head = [bold(`${l.address} · ${money(l.price)}`), dim(`${l.tone} tone · ${approvalLabel(signed, l.assets.length)}`), ...(whenLine === null ? [] : [whenLine]), ""]; | |
| 109 | 120 | // Scroll so the asset under the cursor starts the page. |
| 110 | 121 | const body = l.assets.slice(cursor).flatMap((a, i) => [ |
| 111 | 122 | `${i === 0 ? "▸" : " "} ${i === 0 ? bold(a.label) : a.label} ${a.check.clear ? green("Fair Housing: clear") : amber(`Fair Housing: ${a.check.findings.length} to review`)}${a.edited ? dim(" edited") : ""}${a.approval === null ? "" : green(` approved by ${a.approval.by_name}`)}`, |
| @@ -129,7 +140,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 129 | 140 | if (previewing) help = "esc back to the assets · q quit"; |
| 130 | 141 | else help = |
| 131 | 142 | found?.ok === true && canEditListing(me, found.value) |
| 132 | ? "j/k asset · e edit · a approve/withdraw · u use suggested wording · t next tone · p preview email · esc back · q quit" | |
| 143 | ? "j/k asset · e edit · o open house · a approve/withdraw · u use suggested wording · t next tone · p preview email · esc back · q quit" | |
| 133 | 144 | : "j/k asset · p preview email · esc back · q quit"; |
| 134 | 145 | } else if (view === "listings") { |
| 135 | 146 | const rows = listings(); |
| @@ -161,7 +172,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 161 | 172 | } |
| 162 | 173 | lines.push(""); |
| 163 | 174 | if (typing !== undefined) { |
| 164 | const label = typing.for === "note" ? "New note" : `Edit (\\n breaks a line, enter saves, esc drops)`; | |
| 175 | const label = typing.for === "note" ? "New note" : typing.for === "open" ? "Open house: date start-end, like 2027-03-09 13:00-15:00; separate two with ; (empty clears, enter saves, esc drops)" : `Edit (\\n breaks a line, enter saves, esc drops)`; | |
| 165 | 176 | // The tail of what is typed, so the cursor stays on the screen. |
| 166 | 177 | const room = Math.max(20, size.columns - 4); |
| 167 | 178 | lines.push(dim(label), `${typed.length > room ? `…${typed.slice(-room + 1)}` : typed}${esc}7m ${esc}0m`); |
| @@ -177,6 +188,9 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 177 | 188 | const added = addNote(db, me, { body: typed }); |
| 178 | 189 | if (!added.ok) said = added.message; |
| 179 | 190 | cursor = 0; |
| 191 | } else if (typing.for === "open") { | |
| 192 | const done = updateFacts(db, me, { id: reading!, fields: { open_houses: openFromLine(typed) } }); | |
| 193 | said = done.ok ? "Open house saved. Every asset is written again: read it through." : done.message; | |
| 180 | 194 | } else { |
| 181 | 195 | const kind = typing.kind; |
| 182 | 196 | const edited = editAsset(db, me, { id: reading!, kind, text: fromLine(typed) }); |
| @@ -222,7 +236,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 222 | 236 | if (previewing) return undefined; |
| 223 | 237 | if (down) cursor = Math.min(cursor + 1, Math.max(0, assets.length - 1)); |
| 224 | 238 | if (up) cursor = Math.max(cursor - 1, 0); |
| 225 | if (found?.ok === true && (key === "e" || key === "t" || key === "a" || key === "u")) { | |
| 239 | if (found?.ok === true && (key === "e" || key === "t" || key === "a" || key === "u" || key === "o")) { | |
| 226 | 240 | if (!canEditListing(me, found.value)) |
| 227 | 241 | said = me.role === "anon" ? "Sign in to edit this listing." : "Only the listing's agent or an admin can edit it."; |
| 228 | 242 | else if (key === "u") { |
| @@ -243,6 +257,9 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 243 | 257 | ? withdrawing ? "Approval withdrawn." : green("Approved.") |
| 244 | 258 | : done.message; |
| 245 | 259 | } |
| 260 | } else if (key === "o") { | |
| 261 | typing = { for: "open" }; | |
| 262 | typed = openToLine(found.value.open_houses); | |
| 246 | 263 | } else if (key === "e") { |
| 247 | 264 | const asset = assets[cursor]; |
| 248 | 265 | if (asset !== undefined) { |
src/walkthrough.test.ts +38 −1
| @@ -172,7 +172,44 @@ test("every step of the section works, with the labels it names", async () => { | ||
| 172 | 172 | expect(afterDrop).toContain("0 of 6 approved"); |
| 173 | 173 | expect(await (await get(`${mine}/flyer`)).text()).toContain("$649,000"); |
| 174 | 174 | |
| 175 | // 7. Print the flyer. | |
| 175 | // 7. Add an open house: Edit facts, the band on the flyer, the line in the email, approvals cleared. | |
| 176 | expect(section).toContain("Add an open house"); | |
| 177 | expect(section).toContain("Open Sunday, March 7, 1–3 pm"); | |
| 178 | const sampleFlyer = await (await get(`/listings/${sample}/flyer`)).text(); | |
| 179 | expect(sampleFlyer).toContain('<p class="flyer-open brand-fill">Come by the open house: Sunday, March 7, 1–3 pm.</p>'); | |
| 180 | const editForm = await (await get(`${mine}/facts`)).text(); | |
| 181 | for (const name of ["open1_date", "open1_start", "open1_end", "open2_date"]) | |
| 182 | expect(editForm).toContain(`name="${name}"`); | |
| 183 | expect(await (await get(`${mine}/flyer`)).text()).not.toContain("flyer-open"); | |
| 184 | const approveOpen = new FormData(); | |
| 185 | approveOpen.set("approved", "1"); | |
| 186 | const beforeOpen = await (await get(mine)).text(); | |
| 187 | approveOpen.set("seen", /name="seen" value="([^"]+)"/.exec(beforeOpen)![1]!.replaceAll("&", "&")); | |
| 188 | approveOpen.set("text", textareaOf(beforeOpen)); | |
| 189 | await post(`${mine}/assets/mls/approve`, approveOpen); | |
| 190 | expect(await (await get(mine)).text()).toContain("1 of 6 approved"); | |
| 191 | const hold = new FormData(); | |
| 192 | for (const [k, v] of drop.entries()) hold.set(k, v); | |
| 193 | hold.set("tone", "bold"); | |
| 194 | hold.set("open1_date", "2027-03-07"); | |
| 195 | hold.set("open1_start", "13:00"); | |
| 196 | hold.set("open1_end", "15:00"); | |
| 197 | expect((await post(`${mine}/facts`, hold)).status).toBe(303); | |
| 198 | const afterOpen = await (await get(mine)).text(); | |
| 199 | expect(afterOpen).toContain("0 of 6 approved"); | |
| 200 | expect(await (await get(`${mine}/flyer`)).text()).toContain("OPEN HOUSE: Sunday, March 7, 1–3 pm."); | |
| 201 | expect(await (await get(`${mine}/email`)).text()).toContain("OPEN HOUSE: Sunday, March 7, 1–3 pm."); | |
| 202 | const bad = new FormData(); | |
| 203 | for (const [k, v] of hold.entries()) bad.set(k, v); | |
| 204 | bad.set("open1_end", "12:00"); | |
| 205 | expect((await post(`${mine}/facts`, bad)).headers.get("location")).toContain("/facts"); | |
| 206 | const clear = new FormData(); | |
| 207 | for (const [k, v] of drop.entries()) clear.set(k, v); | |
| 208 | for (const k of ["open1_date", "open1_start", "open1_end"]) clear.set(k, ""); | |
| 209 | expect((await post(`${mine}/facts`, clear)).status).toBe(303); | |
| 210 | expect(await (await get(`${mine}/flyer`)).text()).not.toContain("flyer-open"); | |
| 211 | ||
| 212 | // 8. Print the flyer. | |
| 176 | 213 | expect(afterTone).toMatch(control("Print flyer")); |
| 177 | 214 | const flyer = await get(`${mine}/flyer`); |
| 178 | 215 | expect(flyer.status).toBe(200); |
static/app.css +21 −1
| @@ -499,6 +499,14 @@ h3 { | ||
| 499 | 499 | font-size: var(); |
| 500 | 500 | font-weight: 600; |
| 501 | 501 | } |
| 502 | .listing-open { | |
| 503 | margin: var() 0 0; | |
| 504 | font-weight: 600; | |
| 505 | } | |
| 506 | .open-grid { | |
| 507 | grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr)); | |
| 508 | margin-bottom: var(); | |
| 509 | } | |
| 502 | 510 | .listing-tools { |
| 503 | 511 | display: flex; |
| 504 | 512 | flex-wrap: wrap; |
| @@ -668,6 +676,12 @@ h3 { | ||
| 668 | 676 | font-weight: 600; |
| 669 | 677 | line-height: 1; |
| 670 | 678 | } |
| 679 | .flyer-open { | |
| 680 | margin: 0; | |
| 681 | padding: var() var(); | |
| 682 | font-weight: 700; | |
| 683 | text-align: center; | |
| 684 | } | |
| 671 | 685 | .flyer-facts { |
| 672 | 686 | list-style: none; |
| 673 | 687 | margin: 0; |
| @@ -745,8 +759,14 @@ h3 { | ||
| 745 | 759 | padding-left: var(); |
| 746 | 760 | padding-right: var(); |
| 747 | 761 | } |
| 762 | .flyer-facts, | |
| 763 | .flyer-open { | |
| 764 | padding-left: var(); | |
| 765 | padding-right: var(); | |
| 766 | } | |
| 748 | 767 | .flyer-facts { |
| 749 | padding: var() var(); | |
| 768 | padding-top: var(); | |
| 769 | padding-bottom: var(); | |
| 750 | 770 | } |
| 751 | 771 | .flyer-agent { |
| 752 | 772 | margin: 0 var(); |
openHouseLine(f) is called twice here; compute it once above the array.