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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/listing-launch:mason/53-make-a-square-picture-for-each-listing-s → main 2 commits fork 2 threads · 2 unresolved

  • Merged. mason/53-make-a-square-picture-for-each-listing-s was merged into main as de61a54.

Review

Sign in to review this change.
10 files changed +244 −6
README.md +4 −3
@@ -85,12 +85,13 @@ to make a listing of your own.
8585 10. **Print the flyer.** Press **Print flyer**: a one-page flyer opens with
8686 the photo, price, facts, description and your name. Print it or save it
8787 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.
outdated

Unnumbered, misindented paragraph inside the numbered walkthrough. Make it step 11, renumber the rest, one line.

89 12. **Download the launch kit.** Press **Download launch kit** at the top of
8990 the listing: one text file named from the address, with the price and
9091 facts, then every asset under its name, each marked "Approved by … on …"
9192 or "DRAFT — not approved", with its Fair Housing findings beneath it. Hand
9293 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
9495 page link (`/homes/:id`) has a Copy button; the flyer prints it and the
9596 email's "See the home" button points to it. Open it on a phone: photos,
9697 price, status, open house, your contact details, and the MLS description
@@ -99,7 +100,7 @@ to make a listing of your own.
99100 `FORGE_APP_URL`, never whatever address the visitor typed; the terminal and the
100101 launch kit print the same URL. The first sample's MLS text is approved so
101102 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
103104 the kit: one page with the address, price, status, photo, the property page
104105 link and a row per asset, each with "Approved by … on …" and its words, or
105106 "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, "&amp;"));
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 => {
202202 return `${day}, ${MONTHS[m - 1]} ${d}, ${timeRange(o.start, o.end)}`;
203203 };
204204
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
205212 /** The open house as a person would say it, in the listing's tone; null when there is none. */
206213 export const openHouseLine = (f: Facts): string | null => {
207214 const when = (f.open_houses ?? []).map(whenOf).join(" and ");
src/hostile.test.ts +1 −1
@@ -93,7 +93,7 @@ const expectInert = (html: string) => {
9393 expect(html).not.toMatch(/<textarea[^>]*>(?:(?!<\/textarea>)[^])*<\/textarea>[^]*?<script>alert/);
9494 };
9595
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"]) {
9797 test(`${path} escapes every typed word`, async () => {
9898 const r = await call(path.replace(":id", String(id)));
9999 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 =>
109109 <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>
110110 </form>`;
111111
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 =>
114114 l.assets.find((a) => a.kind === kind)?.approval == null
115115 ? `<p class="draft-band" role="note">Draft — not approved</p>`
116116 : "";
@@ -259,6 +259,7 @@ export function listingPage(input: {
259259 ${TABS.map(
260260 (t) => `<div class="tabpanel" role="tabpanel" id="panel-${t.id}" aria-labelledby="tab-${t.id}"${t.id === open ? "" : " hidden"}>
261261 ${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>` : ""}
262263 ${t.id === "email" ? `<p><a class="button button-quiet" href="/listings/${l.id}/email">Preview email</a></p>` : ""}
263264 ${l.assets
264265 .filter((a) => t.kinds.includes(a.kind))
src/routes.ts +7
@@ -73,6 +73,7 @@ import { propertyPage } from "./pages/property.ts";
7373 import { isPost, lengthLine } from "./postlength.ts";
7474 import { reportPage } from "./pages/report.ts";
7575 import { signInPage } from "./pages/signin.ts";
76 import { cardPage } from "./pages/card.ts";
7677 import { approval, asideOf, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts";
7778 import { notesPage } from "./pages/notes.ts";
7879 import { pathOf, PHOTOS_MAX, PHOTOS_TOTAL, typeOf } from "./photos.ts";
@@ -553,6 +554,12 @@ export const routes: readonly Route[] = [
553554 flyerPage({ who: c.who, listing, origin: publicOrigin }),
554555 ),
555556 },
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 },
556563 {
557564 method: "GET",
558565 path: "/listings/:id/sign-in",
src/speed.test.ts +1
@@ -19,6 +19,7 @@ const PAGES = [
1919 "/",
2020 "/listings/1",
2121 "/listings/1/flyer",
22 "/listings/1/card",
2223 "/listings/1/email",
2324 "/api/listings",
2425 ];
static/app.css +81
@@ -1170,3 +1170,84 @@ main:focus {
11701170 max-height: 2.2in;
11711171 }
11721172 }
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(--color-brand-on);
1187 background: var(--color-paper);
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(--space-2);
1214 max-height: 560px;
1215 padding: var(--space-5) 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(--font-display);
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 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs