Public: anyone can read this; the crew can change it.
Make a square picture for each listing's social posts: hero photo, price and a Just listed / Open house / Sold band in the agent's brand #66
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/53-make-a-square-picture-for-each-listing-s → main 2 commits fork 2 threads · 2 unresolved
10 files
changed +244
−6
README.md +4 −3
| @@ -85,12 +85,13 @@ to make a listing of your own. | ||
| 85 | 85 | 10. **Print the flyer.** Press **Print flyer**: a one-page flyer opens with |
| 86 | 86 | the photo, price, facts, description and your name. Print it or save it |
| 87 | 87 | as a PDF. |
| 88 | 11. **Download the launch kit.** Press **Download launch kit** at the top of | |
| 88 | 11. **Make a square picture.** Under the Social tab press **Square picture**, then save it as a PDF or take a screenshot; it wears "Draft — not approved" until the first social post is approved. | |
| 89 | 12. **Download the launch kit.** Press **Download launch kit** at the top of | |
| 89 | 90 | the listing: one text file named from the address, with the price and |
| 90 | 91 | facts, then every asset under its name, each marked "Approved by … on …" |
| 91 | 92 | or "DRAFT — not approved", with its Fair Housing findings beneath it. Hand |
| 92 | 93 | it to an assistant or a coordinator. |
| 93 | 12. **Show buyers the home.** Under the facts on the listing, the property | |
| 94 | 13. **Show buyers the home.** Under the facts on the listing, the property | |
| 94 | 95 | page link (`/homes/:id`) has a Copy button; the flyer prints it and the |
| 95 | 96 | email's "See the home" button points to it. Open it on a phone: photos, |
| 96 | 97 | price, status, open house, your contact details, and the MLS description |
| @@ -99,7 +100,7 @@ to make a listing of your own. | ||
| 99 | 100 | `FORGE_APP_URL`, never whatever address the visitor typed; the terminal and the |
| 100 | 101 | launch kit print the same URL. The first sample's MLS text is approved so |
| 101 | 102 | the preview shows a page. |
| 102 | 13. **Print the seller report.** Press **Seller report** beside the flyer and | |
| 103 | 14. **Print the seller report.** Press **Seller report** beside the flyer and | |
| 103 | 104 | the kit: one page with the address, price, status, photo, the property page |
| 104 | 105 | link and a row per asset, each with "Approved by … on …" and its words, or |
| 105 | 106 | "Draft — not approved yet" with no words. Print it at the end of the meeting. In the terminal, `R` on an open |
docs/design/areas/card.md added +5
| @@ -0,0 +1,5 @@ | ||
| 1 | # Square picture | |
| 2 | ||
| 3 | New component classes, all built from existing tokens: | |
| 4 | ||
| 5 | - `card-square`, `card-photo`, `card-band`, `card-headline`, `card-price`, `card-facts`, `card-address`, `card-agent` — the 1080 × 1080 picture at `/listings/:id/card` (`static/app.css`): the hero photo (or the brand colour) filling the square, with a `brand-fill` band along the bottom; it prints as one page of its own size. |
src/card.test.ts added +87
| @@ -0,0 +1,87 @@ | ||
| 1 | /** The square picture at /listings/:id/card: one headline by precedence, a draft band until the first social post is approved. */ | |
| 2 | import { afterAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { approveAsset, getListing, launchListing, setStatus, updateFacts } from "./actions.ts"; | |
| 7 | import { openDb } from "./db.ts"; | |
| 8 | import { cardPage } from "./pages/card.ts"; | |
| 9 | import { listingPage } from "./pages/listing.ts"; | |
| 10 | import { SAMPLE } from "./sample.ts"; | |
| 11 | import { serve } from "./server.ts"; | |
| 12 | import type { Who } from "./who.ts"; | |
| 13 | ||
| 14 | const me: Who = { role: "user", id: "u1", name: "me" }; | |
| 15 | ||
| 16 | const launch = (extra: Record<string, unknown> = {}) => { | |
| 17 | const db = openDb(mkdtempSync(join(tmpdir(), "app-card-"))); | |
| 18 | const made = launchListing(db, me, { fields: { ...SAMPLE, price: 899000, ...extra } }); | |
| 19 | if (!made.ok) throw new Error(made.message); | |
| 20 | return { db, id: made.value.id }; | |
| 21 | }; | |
| 22 | const card = (db: ReturnType<typeof openDb>, id: number): string => { | |
| 23 | const found = getListing(db, me, { id }); | |
| 24 | if (!found.ok) throw new Error(found.message); | |
| 25 | return cardPage({ who: me, listing: found.value }); | |
| 26 | }; | |
| 27 | const headline = (html: string): string => /<p class="card-headline">([^<]*)<\/p>/.exec(html)![1]!; | |
| 28 | ||
| 29 | test("an active listing says Just listed, with price, facts, address and agent", () => { | |
| 30 | const { db, id } = launch({ open_houses: [] }); | |
| 31 | const html = card(db, id); | |
| 32 | expect(headline(html)).toBe("Just listed"); | |
| 33 | expect(html).toContain("$899,000"); | |
| 34 | expect(html).toContain(SAMPLE.address.replace(/&/g, "&")); | |
| 35 | expect(html).toContain(SAMPLE.agent_name); | |
| 36 | expect(html).toContain("print-bar"); | |
| 37 | expect(html).toContain("data-print"); | |
| 38 | expect(html).toContain('class="print"'); | |
| 39 | }); | |
| 40 | ||
| 41 | test("an open house, a lowered price, under contract and sold each take the headline in that order", () => { | |
| 42 | const { db, id } = launch({ open_houses: [{ date: "2027-03-07", start: "13:00", end: "15:00" }] }); | |
| 43 | expect(headline(card(db, id))).toBe("Open house Sun, Mar 7, 1–3 pm"); | |
| 44 | expect(updateFacts(db, me, { id, fields: { price: 849000 } }).ok).toBe(true); | |
| 45 | expect(headline(card(db, id))).toBe("New price"); | |
| 46 | expect(setStatus(db, me, { id, status: "under_contract" }).ok).toBe(true); | |
| 47 | expect(headline(card(db, id))).toBe("Under contract"); | |
| 48 | expect(setStatus(db, me, { id, status: "sold" }).ok).toBe(true); | |
| 49 | expect(headline(card(db, id))).toBe("Sold"); | |
| 50 | }); | |
| 51 | ||
| 52 | test("a coming-soon listing ignores its open house, as the property page does", () => { | |
| 53 | const { db, id } = launch({ open_houses: [{ date: "2027-03-07", start: "13:00", end: "15:00" }] }); | |
| 54 | expect(setStatus(db, me, { id, status: "coming_soon" }).ok).toBe(true); | |
| 55 | expect(headline(card(db, id))).toBe("Just listed"); | |
| 56 | }); | |
| 57 | ||
| 58 | test("a listing with no photo gets the brand colour, not a broken image", () => { | |
| 59 | const { db, id } = launch({ open_houses: [] }); | |
| 60 | const html = card(db, id); | |
| 61 | expect(html).not.toContain("<img"); | |
| 62 | expect(html).toContain('class="card-photo brand-fill"'); | |
| 63 | }); | |
| 64 | ||
| 65 | test("the draft band stays until the first social post is approved", () => { | |
| 66 | const { db, id } = launch(); | |
| 67 | expect(card(db, id)).toContain("Draft — not approved"); | |
| 68 | const found = getListing(db, me, { id }); | |
| 69 | if (!found.ok) throw new Error(found.message); | |
| 70 | const post = found.value.assets.find((a) => a.kind === "social_short")!; | |
| 71 | expect(approveAsset(db, me, { id, kind: "social_short", door: "page", seen: post.updated_at }).ok).toBe(true); | |
| 72 | expect(card(db, id)).not.toContain("Draft — not approved"); | |
| 73 | }); | |
| 74 | ||
| 75 | test("the Social tab links to it", () => { | |
| 76 | const { db, id } = launch(); | |
| 77 | const found = getListing(db, me, { id }); | |
| 78 | if (!found.ok) throw new Error(found.message); | |
| 79 | expect(listingPage({ who: me, listing: found.value, editable: true })).toContain(`href="/listings/${id}/card"`); | |
| 80 | }); | |
| 81 | ||
| 82 | test("an unknown id is 404", async () => { | |
| 83 | const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "card-")), identityKey: "k" }); | |
| 84 | afterAll(() => server.stop(true)); | |
| 85 | const base = `http://${server.hostname}:${server.port}`; | |
| 86 | expect((await fetch(`${base}/listings/9999/card`)).status).toBe(404); | |
| 87 | }); |
src/generate.ts +7
| @@ -202,6 +202,13 @@ export const whenOf = (o: OpenHouse): string => { | ||
| 202 | 202 | return `${day}, ${MONTHS[m - 1]} ${d}, ${timeRange(o.start, o.end)}`; |
| 203 | 203 | }; |
| 204 | 204 | |
| 205 | /** "Sun, Mar 7, 1–3 pm": one open house, short enough for a band on a square picture. */ | |
| 206 | export const whenShort = (o: OpenHouse): string => { | |
| 207 | const [y, m, d] = o.date.split("-").map(Number) as [number, number, number]; | |
| 208 | const day = WEEKDAYS[new Date(Date.UTC(y, m - 1, d)).getUTCDay()]!; | |
| 209 | return `${day.slice(0, 3)}, ${MONTHS[m - 1]!.slice(0, 3)} ${d}, ${timeRange(o.start, o.end)}`; | |
| 210 | }; | |
| 211 | ||
| 205 | 212 | /** The open house as a person would say it, in the listing's tone; null when there is none. */ |
| 206 | 213 | export const openHouseLine = (f: Facts): string | null => { |
| 207 | 214 | const when = (f.open_houses ?? []).map(whenOf).join(" and "); |
src/hostile.test.ts +1 −1
| @@ -93,7 +93,7 @@ const expectInert = (html: string) => { | ||
| 93 | 93 | expect(html).not.toMatch(/<textarea[^>]*>(?:(?!<\/textarea>)[^])*<\/textarea>[^]*?<script>alert/); |
| 94 | 94 | }; |
| 95 | 95 | |
| 96 | for (const path of ["/", "/listings/:id", "/listings/:id/flyer", "/listings/:id/report", "/listings/:id/sign-in", "/listings/:id/email", "/homes/:id"]) { | |
| 96 | for (const path of ["/", "/listings/:id", "/listings/:id/flyer", "/listings/:id/card", "/listings/:id/report", "/listings/:id/sign-in", "/listings/:id/email", "/homes/:id"]) { | |
| 97 | 97 | test(`${path} escapes every typed word`, async () => { |
| 98 | 98 | const r = await call(path.replace(":id", String(id))); |
| 99 | 99 | expect(r.status).toBe(200); |
src/pages/card.ts added +48
| @@ -0,0 +1,48 @@ | ||
| 1 | /** | |
| 2 | * The square picture at `/listings/:id/card`: 1080 × 1080 CSS pixels for a | |
| 3 | * social post — the hero photo, a band in the agent's colour with one headline, | |
| 4 | * the price, the facts, the address and the agent. Facts and brand only, never | |
| 5 | * asset text. The agent saves it as a PDF or a screenshot. | |
| 6 | */ | |
| 7 | ||
| 8 | import { factItems, money, priceCut, whenShort } from "../generate.ts"; | |
| 9 | import { escape } from "../http.ts"; | |
| 10 | import type { Launched } from "../listing.ts"; | |
| 11 | import type { Who } from "../who.ts"; | |
| 12 | import { layout } from "./layout.ts"; | |
| 13 | import { draftBand, photoUrl } from "./listing.ts"; | |
| 14 | ||
| 15 | /** The one headline: where the deal stands, else a lowered price, else the open house, else just listed. */ | |
| 16 | export function cardHeadline(l: Launched): string { | |
| 17 | if (l.status === "sold") return "Sold"; | |
| 18 | if (l.status === "under_contract") return "Under contract"; | |
| 19 | if (priceCut(l)) return "New price"; | |
| 20 | const open = l.status === "active" ? l.open_houses[0] : undefined; | |
outdated
Coming soon is outside the issue and untested; drop it or test it. Open house is also shown regardless of status here, unlike the property page. |
||
| 21 | if (open !== undefined) return `Open house ${whenShort(open)}`; | |
| 22 | return "Just listed"; | |
| 23 | } | |
| 24 | ||
| 25 | export function cardPage(input: { who: Who; listing: Launched }): string { | |
| 26 | const { who, listing: l } = input; | |
| 27 | const hero = l.photos[0]; | |
| 28 | return layout( | |
| 29 | `<div class="print-bar row"> | |
| 30 | <a href="/listings/${l.id}">← Back to the listing</a> | |
| 31 | <button type="button" class="button" data-print hidden>Print</button> | |
| 32 | <span class="muted">A 1080 × 1080 square: save as PDF or take a screenshot.</span> | |
| 33 | </div> | |
| 34 | <article class="card-square palette-${escape(l.palette)}"> | |
| 35 | ${hero === undefined ? `<div class="card-photo brand-fill"></div>` : `<img class="card-photo" src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="1080" height="1080">`} | |
| 36 | ${draftBand(l, "social_short")} | |
| 37 | <div class="card-band brand-fill"> | |
| 38 | <p class="card-headline">${escape(cardHeadline(l))}</p> | |
| 39 | <p class="card-price">${money(l.price)}</p> | |
| 40 | <p class="card-facts">${escape(factItems(l).slice(0, 3).map((i) => `${i.value} ${i.label}`).join(" · "))}</p> | |
| 41 | <p class="card-address">${escape(l.address)}</p> | |
| 42 | <p class="card-agent">${escape(l.agent_name)} · ${escape(l.brokerage)} · ${escape(l.agent_phone)}</p> | |
| 43 | </div> | |
| 44 | </article>`, | |
| 45 | who, | |
| 46 | { path: `/listings/${l.id}/card`, heading: `Square picture: ${l.address}`, kind: "print" }, | |
| 47 | ); | |
| 48 | } |
src/pages/listing.ts +3 −2
| @@ -109,8 +109,8 @@ export const statusForm = (l: Listing): string => | ||
| 109 | 109 | <button class="button button-quiet" title="Coming soon, under contract and sold add a status post and a status email; your other assets and approvals stay." aria-label="Set status for this listing">Set status</button> |
| 110 | 110 | </form>`; |
| 111 | 111 | |
| 112 | /** The band an unapproved flyer or email wears, on screen and on paper; nothing once approved. */ | |
| 113 | export const draftBand = (l: Launched, kind: "flyer" | "email"): string => | |
| 112 | /** The band an unapproved flyer, email or square picture (its first social post) wears, on screen and on paper; nothing once approved. */ | |
| 113 | export const draftBand = (l: Launched, kind: "flyer" | "email" | "social_short"): string => | |
| 114 | 114 | l.assets.find((a) => a.kind === kind)?.approval == null |
| 115 | 115 | ? `<p class="draft-band" role="note">Draft — not approved</p>` |
| 116 | 116 | : ""; |
| @@ -259,6 +259,7 @@ export function listingPage(input: { | ||
| 259 | 259 | ${TABS.map( |
| 260 | 260 | (t) => `<div class="tabpanel" role="tabpanel" id="panel-${t.id}" aria-labelledby="tab-${t.id}"${t.id === open ? "" : " hidden"}> |
| 261 | 261 | ${t.id === "flyer" ? `<div class="flyer-preview"><a href="/listings/${l.id}/flyer" aria-label="Open the printable flyer">${flyer(l, origin)}</a></div>` : ""} |
| 262 | ${t.id === "social" ? `<p><a class="button button-quiet" href="/listings/${l.id}/card">Square picture</a></p>` : ""} | |
| 262 | 263 | ${t.id === "email" ? `<p><a class="button button-quiet" href="/listings/${l.id}/email">Preview email</a></p>` : ""} |
| 263 | 264 | ${l.assets |
| 264 | 265 | .filter((a) => t.kinds.includes(a.kind)) |
src/routes.ts +7
| @@ -73,6 +73,7 @@ import { propertyPage } from "./pages/property.ts"; | ||
| 73 | 73 | import { isPost, lengthLine } from "./postlength.ts"; |
| 74 | 74 | import { reportPage } from "./pages/report.ts"; |
| 75 | 75 | import { signInPage } from "./pages/signin.ts"; |
| 76 | import { cardPage } from "./pages/card.ts"; | |
| 76 | 77 | import { approval, asideOf, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts"; |
| 77 | 78 | import { notesPage } from "./pages/notes.ts"; |
| 78 | 79 | import { pathOf, PHOTOS_MAX, PHOTOS_TOTAL, typeOf } from "./photos.ts"; |
| @@ -553,6 +554,12 @@ export const routes: readonly Route[] = [ | ||
| 553 | 554 | flyerPage({ who: c.who, listing, origin: publicOrigin }), |
| 554 | 555 | ), |
| 555 | 556 | }, |
| 557 | { | |
| 558 | method: "GET", | |
| 559 | path: "/listings/:id/card", | |
| 560 | handle: (c) => | |
| 561 | page(c.who, getListing(c.db, c.who, { id: Number(c.params.id) }), (listing) => cardPage({ who: c.who, listing })), | |
| 562 | }, | |
| 556 | 563 | { |
| 557 | 564 | method: "GET", |
| 558 | 565 | path: "/listings/:id/sign-in", |
src/speed.test.ts +1
| @@ -19,6 +19,7 @@ const PAGES = [ | ||
| 19 | 19 | "/", |
| 20 | 20 | "/listings/1", |
| 21 | 21 | "/listings/1/flyer", |
| 22 | "/listings/1/card", | |
| 22 | 23 | "/listings/1/email", |
| 23 | 24 | "/api/listings", |
| 24 | 25 | ]; |
static/app.css +81
| @@ -1170,3 +1170,84 @@ main:focus { | ||
| 1170 | 1170 | max-height: 2.2in; |
| 1171 | 1171 | } |
| 1172 | 1172 | } |
| 1173 | ||
| 1174 | /* The square picture: 1080 × 1080 CSS pixels, the hero filling it, the agent's colour in a band along the bottom. */ | |
| 1175 | @page card { | |
| 1176 | size: 1080px 1080px; | |
| 1177 | margin: 0; | |
| 1178 | } | |
| 1179 | .card-square { | |
| 1180 | page: card; | |
| 1181 | position: relative; | |
| 1182 | width: 1080px; | |
| 1183 | height: 1080px; | |
| 1184 | margin: 0 auto; | |
| 1185 | overflow: hidden; | |
| 1186 | color: var(); | |
| 1187 | background: var(); | |
| 1188 | } | |
| 1189 | .card-square p { | |
| 1190 | margin: 0; | |
| 1191 | overflow-wrap: anywhere; | |
| 1192 | } | |
| 1193 | .card-photo { | |
| 1194 | position: absolute; | |
| 1195 | inset: 0; | |
| 1196 | width: 100%; | |
| 1197 | height: 100%; | |
| 1198 | object-fit: cover; | |
| 1199 | } | |
| 1200 | .card-square .draft-band { | |
| 1201 | position: absolute; | |
| 1202 | top: 0; | |
| 1203 | left: 0; | |
| 1204 | right: 0; | |
| 1205 | } | |
| 1206 | .card-band { | |
| 1207 | position: absolute; | |
| 1208 | left: 0; | |
| 1209 | right: 0; | |
| 1210 | bottom: 0; | |
| 1211 | display: flex; | |
| 1212 | flex-direction: column; | |
| 1213 | gap: var(); | |
| 1214 | max-height: 560px; | |
| 1215 | padding: var() 48px; | |
| 1216 | overflow: hidden; | |
| 1217 | } | |
| 1218 | .card-headline { | |
| 1219 | font-size: 64px; | |
| 1220 | font-weight: 700; | |
| 1221 | line-height: 1.1; | |
| 1222 | text-transform: uppercase; | |
| 1223 | letter-spacing: 0.04em; | |
| 1224 | } | |
| 1225 | .card-price { | |
| 1226 | font-family: var(); | |
| 1227 | font-size: 96px; | |
| 1228 | font-weight: 600; | |
| 1229 | line-height: 1; | |
| 1230 | } | |
| 1231 | .card-facts, | |
| 1232 | .card-address { | |
| 1233 | font-size: 40px; | |
| 1234 | line-height: 1.2; | |
| 1235 | } | |
| 1236 | .card-address { | |
| 1237 | display: -webkit-box; | |
| 1238 | -webkit-line-clamp: 2; | |
| 1239 | -webkit-box-orient: vertical; | |
| 1240 | overflow: hidden; | |
| 1241 | } | |
| 1242 | .card-agent { | |
| 1243 | font-size: 32px; | |
| 1244 | opacity: 0.9; | |
| 1245 | } | |
| 1246 | @media print { | |
| 1247 | .card-square { | |
| 1248 | box-shadow: none; | |
| 1249 | -webkit-print-color-adjust: exact; | |
| 1250 | print-color-adjust: exact; | |
| 1251 | break-inside: avoid; | |
| 1252 | } | |
| 1253 | } |
Unnumbered, misindented paragraph inside the numbered walkthrough. Make it step 11, renumber the rest, one line.