Public: anyone can read this; the crew can change it.

Print a QR code on the flyer and the open house sign-in sheet that opens the listing's property page #98

Merged opened this

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

Merged

mason/listing-launch:mason/83-print-a-qr-code-on-the-flyer-and-the-ope → main 3 commits fork

  • Merged. mason/83-print-a-qr-code-on-the-flyer-and-the-ope was merged into main as 285baa9.

Review

Sign in to review this change.
32 files changed +515 −7
.bash_profile added
No content changes.
.bashrc added
No content changes.
.claude/agents added
No content changes.
.claude/commands added
No content changes.
.claude/hooks added
No content changes.
.claude/launch.json added
No content changes.
.claude/loop.md added
No content changes.
.claude/output-styles added
No content changes.
.claude/routines added
No content changes.
.claude/scheduled_tasks.json added
No content changes.
.claude/settings.json added
No content changes.
.claude/settings.local.json added
No content changes.
.claude/skills added
No content changes.
.claude/workflows added
No content changes.
.gitconfig added
No content changes.
.gitmodules added
No content changes.
.idea added
No content changes.
.mcp.json added
No content changes.
.profile added
No content changes.
.ripgreprc added
No content changes.
.vscode added
No content changes.
.zprofile added
No content changes.
.zshrc added
No content changes.
README.md +9 −3
@@ -277,9 +277,14 @@ list cannot drift from the app.
277277 viewer may list; `esc` goes back.
278278 5. **Print the flyer.** "Print flyer" opens a letter-size page — hero photo,
279279 price, a strip of the facts, the description, your photo, name,
280 brokerage and license number, and the Equal Housing Opportunity line.
281 Print it, or save it as a PDF. "Rewrite all" writes every asset again in
282 another tone (your edits are replaced, so it asks with a tooltip first).
280 brokerage and license number, and the Equal Housing Opportunity line. It
281 also carries a QR code, labelled "Scan to see the home", beside the
282 property page link in words: a buyer scans it with a phone and lands on
283 `/homes/:id`. The open house sign-in sheet carries the same code. Until the
284 MLS description is approved the page is not public, so both say so in place
285 of the code. Print it, or save it as a PDF. "Rewrite all" writes every
286 asset again in another tone (your edits are replaced, so it asks with a
287 tooltip first).
283288
284289 **Showings.** After each showing, write it down on the listing page: the day
285290 (today unless you change it; never a future one), the buyer's agent if you
@@ -387,6 +392,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
387392 | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` |
388393 | `src/routes.ts` | every path, in one table, each API route described beside it |
389394 | `src/openapi.ts` | the routes as OpenAPI; committed at `contract/openapi.json` |
395 | `src/qr.ts` | a string to a QR module matrix (byte mode, level M, versions 1-10) and its SVG; no dependency |
390396 | `src/pages/` | the HTML, server-rendered: `layout`, home, listing and flyer, notes |
391397 | `design/tokens.json`, `src/tokens.ts` | the look as data, light and dark; compiled to `/tokens.css` |
392398 | `src/http.ts` | the floor: headers, body cap, files |
src/pages/listing.ts +2 −1
@@ -27,6 +27,7 @@ import type { Who } from "../who.ts";
2727 import { dayOf } from "../showings.ts";
2828 import { ago, layout, toast } from "./layout.ts";
2929 import { daysLineHtml, priceHistoryTable } from "./market.ts";
30 import { scanBlock } from "./scan.ts";
3031 import { showingsSection } from "./showings.ts";
3132
3233 /** Where a listing's photo is served: a sample from static/, an upload from its listing. */
@@ -330,7 +331,7 @@ export function flyer(l: Launched, origin = ""): string {
330331 <p>${escape(l.agent_phone)} · ${escape(l.agent_email)}</p>
331332 <p class="flyer-license">License ${escape(l.license)}</p>
332333 </div>
333 <p class="flyer-link">See every photo: ${escape(propertyPath(l, origin))}</p>
334 ${scanBlock(l, origin)}
334335 <p class="flyer-fine">Equal Housing Opportunity. Information provided by the listing agent; deemed reliable, not guaranteed.</p>
335336 </footer>
336337 </article>`;
src/pages/scan.ts added +19
@@ -0,0 +1,19 @@
1 /**
2 * The "Scan to see the home" block printed on the flyer and the open house
3 * sign-in sheet: a QR code for the property page, its URL in words, or, while
4 * the page is not public, a line saying so instead of a code that leads nowhere.
5 */
6
7 import { escape } from "../http.ts";
8 import { propertyPath, publicDescription, type Launched } from "../listing.ts";
9 import { qrSvg } from "../qr.ts";
10
11 export function scanBlock(l: Launched, origin: string): string {
12 if (publicDescription(l) === null)
13 return `<div class="scan scan-pending" role="note"><p>The property page is not public yet: approve the MLS description to print its QR code here.</p></div>`;
14 const url = propertyPath(l, origin);
15 return `<div class="scan">
16 ${qrSvg(url, "QR code for the property page")}
17 <div><p class="scan-label"><b>Scan to see the home</b></p><p class="scan-url">${escape(url)}</p></div>
18 </div>`;
19 }
src/pages/signin.ts +3 −1
@@ -9,13 +9,14 @@ import type { Launched } from "../listing.ts";
99 import type { Who } from "../who.ts";
1010 import { layout } from "./layout.ts";
1111 import { photoUrl } from "./listing.ts";
12 import { scanBlock } from "./scan.ts";
1213
1314 export const SIGN_IN_ROWS = 15;
1415
1516 const blankRows = Array.from({ length: SIGN_IN_ROWS }, () => `<tr><td></td><td></td><td></td><td></td></tr>`).join("\n ");
1617
1718 /** The sheet, or null when the listing has no open house to sign in to. */
18 export function signInPage(input: { who: Who; listing: Launched }): string | null {
19 export function signInPage(input: { who: Who; listing: Launched; origin?: string }): string | null {
1920 const { who, listing: l } = input;
2021 const when = openHouseLine(l);
2122 if (when === null) return null;
@@ -36,6 +37,7 @@ export function signInPage(input: { who: Who; listing: Launched }): string | nul
3637 <p class="signin-agent"><b class="brand">${escape(l.agent_name)}</b> · ${escape(l.agent_phone)}</p>
3738 </div>
3839 </header>
40 ${scanBlock(l, input.origin ?? "")}
3941 <h3>Open house sign-in</h3>
4042 <table class="signin-table">
4143 <thead><tr><th scope="col">Name</th><th scope="col">Email</th><th scope="col">Phone</th><th scope="col">Working with an agent? (Yes / No)</th></tr></thead>
src/qr.test.ts added +121
@@ -0,0 +1,121 @@
1 /**
2 * The QR encoder. The fixture below is the module matrix for one fixed URL as
3 * produced by the npm package `qrcode` 1.5 (soldair/node-qrcode), a widely used
4 * encoder, run once with error correction M; it is not made by src/qr.ts. The
5 * package is not a dependency: it was installed in a scratch directory only to
6 * make the fixture. Beyond the fixture, structure is asserted from the spec.
7 */
8 import { expect, test } from "bun:test";
9 import { MAX_BYTES, qrMatrix, qrSvg } from "./qr.ts";
10
11 const URL_42 = "https://listing-launch--forge-trevato-apps.open-platform.sh/homes/42";
12 const FIXTURE: readonly string[] = [
13 "#######.##..#..###.###..#.#...#######",
14 "#.....#.######..##.#.##.##..#.#.....#",
15 "#.###.#.#..###.#..##.##..##.#.#.###.#",
16 "#.###.#..#.##.#.##...#..##..#.#.###.#",
17 "#.###.#.######.##.##.#...##...#.###.#",
18 "#.....#..#..#.##....####......#.....#",
19 "#######.#.#.#.#.#.#.#.#.#.#.#.#######",
20 ".........#......####.....####........",
21 "#..######.######...#.###...#.#..#.###",
22 "#.##...##.#.....#.#..#######.#.##.##.",
23 "###..##.##.....######.##...#...###..#",
24 "..#..#...#.###..##.##......#.##..####",
25 "#.##.####.##..##....#...#...#.##.#..#",
26 "....##.#.#.#.####....####.###...##.#.",
27 ".##..##..#..#..##.##......#####.#####",
28 ".#...#.#.##.###.#..####....#..##.###.",
29 "..#.#.#....#.#.#.##..##.##....#...#..",
30 "..##...##..#.#...#....#.#..###.#.##..",
31 "..###.#..#..##..##.#..##.###.#.##.#.#",
32 "##.#.#.##.##...##.###..###..###..#..#",
33 "..##..##..##.##..#.##...#.#.######...",
34 "#..###.###.#.#.#.###..####.#.#..#.#..",
35 "#.#...#.#.#.#..#....##.##.###.##.#..#",
36 "..##....#...##..#.#.#..##...##.#####.",
37 "..######.......#.#.####.....#.##.....",
38 "#...#.....#.#.#..###..##.####...###..",
39 "####.###.#.#...#.#.......####.##.#.##",
40 "#.###..##...#...#..#.......###..#.#.#",
41 "#..#######..##.##.##..####.########.#",
42 "........######.#.######..#..#...#.##.",
43 "#######.#.#..###.###...#....#.#.#..##",
44 "#.....#.#####.#....#.#.###.##...#..#.",
45 "#.###.#.#.##..###.#.##..#.#######...#",
46 "#.###.#.#...#.#..#..#.##.##.###..#..#",
47 "#.###.#..#...##..#...###.#......#...#",
48 "#.....#.....#..#.#.....#...#....#####",
49 "#######.#.##....#..##.#.#...#..#....#",
50 ];
51 const rows = (m: boolean[][]) => m.map((r) => r.map((v) => (v ? "#" : ".")).join(""));
52
53 test("the matrix for a fixed URL equals the known-good encoder's", () => {
54 expect(rows(qrMatrix(URL_42))).toEqual([...FIXTURE]);
55 });
56
57 test("finder, separator and timing patterns are where the spec puts them", () => {
58 const m = qrMatrix(URL_42);
59 const n = m.length;
60 expect(n).toBe(37); // version 5
61 for (const [r0, c0] of [[0, 0], [0, n - 7], [n - 7, 0]]) {
62 for (let dr = 0; dr < 7; dr++)
63 for (let dc = 0; dc < 7; dc++) {
64 const ring = Math.max(Math.abs(dr - 3), Math.abs(dc - 3));
65 expect(m[r0 + dr][c0 + dc]).toBe(ring !== 2);
66 }
67 }
68 for (let i = 0; i < 8; i++) expect(m[7][i]).toBe(false); // separator below the top-left finder
69 for (let i = 8; i < n - 8; i++) {
70 expect(m[6][i]).toBe(i % 2 === 0);
71 expect(m[i][6]).toBe(i % 2 === 0);
72 }
73 expect(m[n - 8][8]).toBe(true); // the always-dark module
74 });
75
76 test("format bits spell error correction M and a valid mask, in both copies", () => {
77 const m = qrMatrix(URL_42);
78 const n = m.length;
79 const first = (i: number) => (i <= 5 ? m[i][8] : i === 6 ? m[7][8] : i === 7 ? m[8][8] : i === 8 ? m[8][7] : m[8][14 - i]);
80 const second = (i: number) => (i < 8 ? m[8][n - 1 - i] : m[n - 15 + i][8]);
81 let a = 0;
82 let b = 0;
83 for (let i = 14; i >= 0; i--) {
84 a = a * 2 + (first(i) ? 1 : 0);
85 b = b * 2 + (second(i) ? 1 : 0);
86 }
87 expect(b).toBe(a);
88 const data = (a ^ 0x5412) >> 10;
89 expect(data >> 3).toBe(0); // level M
90 // The ten check bits are the BCH remainder of the five data bits.
91 let v = data << 10;
92 for (let i = 14; i >= 10; i--) if ((v >> i) & 1) v ^= 0x537 << (i - 10);
93 expect((a ^ 0x5412) & 0x3ff).toBe(v);
94 });
95
96 test("the smallest version that fits is chosen, up to version 10", () => {
97 expect(qrMatrix("hi").length).toBe(21);
98 expect(qrMatrix("x".repeat(14)).length).toBe(21);
99 expect(qrMatrix("x".repeat(15)).length).toBe(25);
100 expect(qrMatrix("x".repeat(213)).length).toBe(57);
101 expect(MAX_BYTES).toBe(213);
102 expect(() => qrMatrix("x".repeat(214))).toThrow();
103 });
104
105 test("version 7 and later carry the version information", () => {
106 const m = qrMatrix("x".repeat(150)); // version 8
107 const n = m.length;
108 expect(n).toBe(49);
109 let bits = 0;
110 for (let i = 17; i >= 0; i--) bits = bits * 2 + (m[Math.floor(i / 3)][(i % 3) + n - 11] ? 1 : 0);
111 expect(bits >> 12).toBe(8);
112 });
113
114 test("the SVG is a black and white path of numbers and nothing typed", () => {
115 const svg = qrSvg(URL_42, "Scan <b>to</b> see the home");
116 expect(svg.startsWith("<svg")).toBe(true);
117 expect(svg).toContain('fill="black"');
118 expect(svg).not.toContain("<b>");
119 expect(svg).not.toContain(URL_42);
120 expect(/^<svg[^>]*viewBox="0 0 45 45"/.test(svg)).toBe(true);
121 });
src/qr.ts added +278
@@ -0,0 +1,278 @@
1 /**
2 * QR codes: a string in, a square matrix of dark/light modules out. Byte mode
3 * (UTF-8), error correction level M, the smallest version that fits, versions
4 * 1 to 10 (up to 213 bytes: any property page URL). No dependencies.
5 */
6
7 /** Per version 1..10 at level M: EC codewords per block, then [blocks, data codewords per block] groups. */
8 const BLOCKS: readonly (readonly [number, ...(readonly [number, number])[]])[] = [
9 [10, [1, 16]],
10 [16, [1, 28]],
11 [26, [1, 44]],
12 [18, [2, 32]],
13 [24, [2, 43]],
14 [16, [4, 27]],
15 [18, [4, 31]],
16 [22, [2, 38], [2, 39]],
17 [22, [3, 36], [2, 37]],
18 [26, [4, 43], [1, 44]],
19 ];
20
21 const ALIGN: readonly (readonly number[])[] = [
22 [], [6, 18], [6, 22], [6, 26], [6, 30], [6, 34], [6, 22, 38], [6, 24, 42], [6, 26, 46], [6, 28, 50],
23 ];
24
25 export const MAX_VERSION = BLOCKS.length;
26
27 const dataCodewords = (version: number): number =>
28 BLOCKS[version - 1].slice(1).reduce((n, g) => n + (g as readonly [number, number])[0] * (g as readonly [number, number])[1], 0);
29
30 /** How many bytes the largest version holds. */
31 export const MAX_BYTES = dataCodewords(MAX_VERSION) - 3;
32
33 // GF(256) with the QR polynomial 0x11D.
34 const EXP = new Uint8Array(512);
35 const LOG = new Uint8Array(256);
36 {
37 let x = 1;
38 for (let i = 0; i < 255; i++) {
39 EXP[i] = x;
40 LOG[x] = i;
41 x <<= 1;
42 if (x & 0x100) x ^= 0x11d;
43 }
44 for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
45 }
46 const mul = (a: number, b: number): number => (a === 0 || b === 0 ? 0 : EXP[LOG[a] + LOG[b]]);
47
48 /** Reed-Solomon remainder of `data` for `ecLen` check codewords. */
49 function reedSolomon(data: readonly number[], ecLen: number): number[] {
50 let gen = [1];
51 for (let i = 0; i < ecLen; i++) {
52 const next = new Array<number>(gen.length + 1).fill(0);
53 gen.forEach((c, j) => {
54 next[j] ^= mul(c, EXP[i]);
55 next[j + 1] ^= c;
56 });
57 gen = next;
58 }
59 // gen is lowest-degree first, monic; divide data(x)*x^ecLen by it.
60 const rem = new Array<number>(ecLen).fill(0);
61 for (const d of data) {
62 const factor = d ^ rem[0];
63 rem.shift();
64 rem.push(0);
65 for (let i = 0; i < ecLen; i++) rem[i] ^= mul(gen[ecLen - 1 - i], factor);
66 }
67 return rem;
68 }
69
70 function bch(value: number, poly: number, bits: number): number {
71 let v = value << bits;
72 const top = 31 - Math.clz32(poly);
73 for (let i = 31 - Math.clz32(v); i >= top; i--) if ((v >>> i) & 1) v ^= poly << (i - top);
74 return (value << bits) | v;
75 }
76
77 const MASKS: readonly ((r: number, c: number) => boolean)[] = [
78 (r, c) => (r + c) % 2 === 0,
79 (r) => r % 2 === 0,
80 (_, c) => c % 3 === 0,
81 (r, c) => (r + c) % 3 === 0,
82 (r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0,
83 (r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
84 (r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0,
85 (r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0,
86 ];
87
88 /** The data codewords: mode, length, bytes, terminator, padding. */
89 function encodeData(bytes: Uint8Array, version: number): number[] {
90 const bits: number[] = [];
91 const put = (value: number, n: number) => {
92 for (let i = n - 1; i >= 0; i--) bits.push((value >>> i) & 1);
93 };
94 put(0b0100, 4);
95 put(bytes.length, version < 10 ? 8 : 16);
96 for (const b of bytes) put(b, 8);
97 const capacity = dataCodewords(version) * 8;
98 put(0, Math.min(4, capacity - bits.length));
99 while (bits.length % 8 !== 0) bits.push(0);
100 const out: number[] = [];
101 for (let i = 0; i < bits.length; i += 8) out.push(parseInt(bits.slice(i, i + 8).join(""), 2));
102 for (let pad = 0xec; out.length < capacity / 8; pad ^= 0xec ^ 0x11) out.push(pad);
103 return out;
104 }
105
106 /** Split into blocks, add check codewords, interleave. */
107 function interleave(data: readonly number[], version: number): number[] {
108 const [ecLen, ...groups] = BLOCKS[version - 1];
109 const blocks: number[][] = [];
110 let at = 0;
111 for (const [count, size] of groups as readonly (readonly [number, number])[])
112 for (let i = 0; i < count; i++, at += size) blocks.push(data.slice(at, at + size));
113 const checks = blocks.map((b) => reedSolomon(b, ecLen));
114 const out: number[] = [];
115 const longest = Math.max(...blocks.map((b) => b.length));
116 for (let i = 0; i < longest; i++) for (const b of blocks) if (i < b.length) out.push(b[i]);
117 for (let i = 0; i < ecLen; i++) for (const c of checks) out.push(c[i]);
118 return out;
119 }
120
121 /**
122 * The module matrix for `text`: `true` is dark. Throws when the text is longer
123 * than `MAX_BYTES` bytes of UTF-8. The mask is the one the spec's penalty rules
124 * pick; `forceMask` (0 to 7) is for tests.
125 */
126 export function qrMatrix(text: string, forceMask?: number): boolean[][] {
127 const bytes = new TextEncoder().encode(text);
128 let version = 1;
129 while (version <= MAX_VERSION && dataCodewords(version) < bytes.length + (version < 10 ? 2 : 3)) version++;
130 if (version > MAX_VERSION) throw new Error(`qr: ${bytes.length} bytes do not fit (most is ${MAX_BYTES})`);
131
132 const size = 17 + 4 * version;
133 const dark: boolean[][] = Array.from({ length: size }, () => new Array<boolean>(size).fill(false));
134 const fixed: boolean[][] = Array.from({ length: size }, () => new Array<boolean>(size).fill(false));
135 const set = (r: number, c: number, v: boolean) => {
136 if (r < 0 || c < 0 || r >= size || c >= size) return;
137 dark[r][c] = v;
138 fixed[r][c] = true;
139 };
140
141 // Finder patterns with their separators, timing patterns.
142 for (const [r0, c0] of [[0, 0], [0, size - 7], [size - 7, 0]])
143 for (let dr = -1; dr <= 7; dr++)
144 for (let dc = -1; dc <= 7; dc++) {
145 const ring = Math.max(Math.abs(dr - 3), Math.abs(dc - 3));
146 set(r0 + dr, c0 + dc, ring !== 2 && ring !== 4);
147 }
148 for (let i = 8; i < size - 8; i++) {
149 set(6, i, i % 2 === 0);
150 set(i, 6, i % 2 === 0);
151 }
152 // Alignment patterns.
153 const pos = ALIGN[version - 1];
154 for (const r of pos)
155 for (const c of pos) {
156 if ((r === 6 && (c === 6 || c === size - 7)) || (r === size - 7 && c === 6)) continue; // under a finder
157 for (let dr = -2; dr <= 2; dr++) for (let dc = -2; dc <= 2; dc++) set(r + dr, c + dc, Math.max(Math.abs(dr), Math.abs(dc)) !== 1);
158 }
159 // Reserve format and version areas; the dark module.
160 const reserve = (r: number, c: number) => {
161 fixed[r][c] = true;
162 };
163 for (let i = 0; i < 9; i++) {
164 reserve(8, i);
165 reserve(i, 8);
166 }
167 for (let i = 0; i < 8; i++) {
168 reserve(8, size - 1 - i);
169 reserve(size - 1 - i, 8);
170 }
171 set(size - 8, 8, true);
172 if (version >= 7) {
173 const info = bch(version, 0x1f25, 12);
174 for (let i = 0; i < 18; i++) {
175 const bit = ((info >>> i) & 1) === 1;
176 const a = Math.floor(i / 3);
177 const b = (i % 3) + size - 11;
178 set(a, b, bit);
179 set(b, a, bit);
180 }
181 }
182
183 // Place the data bits in the zigzag, then mask.
184 const codewords = interleave(encodeData(bytes, version), version);
185 const bits: boolean[] = [];
186 for (const cw of codewords) for (let i = 7; i >= 0; i--) bits.push(((cw >>> i) & 1) === 1);
187 const free: [number, number][] = [];
188 for (let right = size - 1; right >= 1; right -= 2) {
189 if (right === 6) right = 5;
190 for (let n = 0; n < size; n++)
191 for (let j = 0; j < 2; j++) {
192 const c = right - j;
193 const r = ((right + 1) & 2) === 0 ? size - 1 - n : n;
194 if (!fixed[r][c]) free.push([r, c]);
195 }
196 }
197 free.forEach(([r, c], i) => {
198 dark[r][c] = i < bits.length ? bits[i] : false;
199 });
200
201 let best: boolean[][] | null = null;
202 let bestScore = Infinity;
203 for (let mask = 0; mask < 8; mask++) {
204 if (forceMask !== undefined && mask !== forceMask) continue;
205 const m = dark.map((row) => row.slice());
206 for (const [r, c] of free) if (MASKS[mask](r, c)) m[r][c] = !m[r][c];
207 drawFormat(m, size, mask);
208 const score = penalty(m);
209 if (score < bestScore) {
210 bestScore = score;
211 best = m;
212 }
213 }
214 return best!;
215 }
216
217 /** Format bits for level M (00) and `mask`, written into both copies. */
218 function drawFormat(m: boolean[][], size: number, mask: number): void {
219 const bits = bch(mask, 0x537, 10) ^ 0x5412; // level M is 00, so the data is just the mask
220 const bit = (i: number) => ((bits >>> i) & 1) === 1;
221 for (let i = 0; i <= 5; i++) m[i][8] = bit(i);
222 m[7][8] = bit(6);
223 m[8][8] = bit(7);
224 m[8][7] = bit(8);
225 for (let i = 9; i < 15; i++) m[8][14 - i] = bit(i);
226 for (let i = 0; i < 8; i++) m[8][size - 1 - i] = bit(i);
227 for (let i = 8; i < 15; i++) m[size - 15 + i][8] = bit(i);
228 m[size - 8][8] = true;
229 }
230
231 /** The spec's four penalty rules; the lowest total picks the mask. */
232 function penalty(m: readonly (readonly boolean[])[]): number {
233 const n = m.length;
234 let score = 0;
235 const line = (get: (i: number) => boolean) => {
236 let run = 1;
237 for (let i = 1; i < n; i++) {
238 if (get(i) === get(i - 1)) {
239 run++;
240 if (run === 5) score += 3;
241 else if (run > 5) score += 1;
242 } else run = 1;
243 }
244 for (let i = 0; i + 11 <= n; i++) {
245 const w = Array.from({ length: 11 }, (_, k) => get(i + k));
246 const a = [true, false, true, true, true, false, true, false, false, false, false];
247 if (w.every((v, k) => v === a[k]) || w.every((v, k) => v === a[10 - k])) score += 40;
248 }
249 };
250 for (let i = 0; i < n; i++) {
251 line((k) => m[i][k]);
252 line((k) => m[k][i]);
253 }
254 for (let r = 0; r < n - 1; r++)
255 for (let c = 0; c < n - 1; c++) if (m[r][c] === m[r][c + 1] && m[r][c] === m[r + 1][c] && m[r][c] === m[r + 1][c + 1]) score += 3;
256 const total = n * n;
257 const darkCount = m.reduce((s, row) => s + row.filter(Boolean).length, 0);
258 score += (Math.ceil(Math.abs(darkCount * 20 - total * 10) / total) - 1) * 10;
259 return score;
260 }
261
262 /**
263 * The matrix as inline SVG: one path of unit squares, black on white, with the
264 * four-module quiet zone. Only numbers go into the markup.
265 */
266 export function qrSvg(text: string, label: string): string {
267 const m = qrMatrix(text);
268 const n = m.length;
269 const quiet = 4;
270 let d = "";
271 m.forEach((row, r) =>
272 row.forEach((on, c) => {
273 if (on) d += `M${c + quiet} ${r + quiet}h1v1h-1z`;
274 }),
275 );
276 const side = n + 2 * quiet;
277 return `<svg class="qr" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${side} ${side}" shape-rendering="crispEdges" role="img" aria-label="${label.replace(/[^A-Za-z0-9 ]/g, "")}"><rect width="${side}" height="${side}" fill="white"/><path d="${d}" fill="black"/></svg>`;
278 }
src/routes.ts +1 −1
@@ -641,7 +641,7 @@ export const routes: readonly Route[] = [
641641 path: "/listings/:id/sign-in",
642642 handle: (c) => {
643643 const found = getListing(c.db, c.who, { id: Number(c.params.id) });
644 const sheet = found.ok ? signInPage({ who: c.who, listing: found.value }) : null;
644 const sheet = found.ok ? signInPage({ who: c.who, listing: found.value, origin: publicOrigin }) : null;
645645 return page(
646646 c.who,
647647 found.ok && sheet === null ? { ok: false, error: "not_found", message: "This listing has no open house." } : found,
src/scan.test.ts added +60
@@ -0,0 +1,60 @@
1 /** The flyer and the sign-in sheet carry a QR code for the property page, once that page is public. */
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 { publicOrigin } from "./env.ts";
7 import { qrMatrix } from "./qr.ts";
8 import { serve } from "./server.ts";
9 import { sign } from "./who.ts";
10
11 const KEY = "a-key-only-the-forge-holds";
12 const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "scan-")), identityKey: KEY });
13 const base = `http://${server.hostname}:${server.port}`;
14 afterAll(() => server.stop(true));
15
16 const identity = `pat|pat|user|${Date.now() + 600_000}`;
17 const auth = { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
18 const call = (path: string, init: RequestInit = {}) => fetch(base + path, { redirect: "manual", ...init, headers: { ...auth, ...(init.headers ?? {}) } });
19
20 const form = new FormData();
21 for (const [k, v] of Object.entries({
22 address: "9 Scan Sq, Portland, OR 97201", beds: 3, baths: 2, sqft: 1800, year: 1999, price: 500000,
23 tone: "classic", palette: "teal", agent_name: "Pat Agent", brokerage: "Harbor Realty", license: "OR-1",
24 agent_phone: "+1 (503) 555-0100", agent_email: "[email protected]", lot: "0.2 acre",
25 features: "Sunny kitchen\nQuiet street\nNew roof\nBig deck\nOak floors",
26 open1_date: "2027-03-06", open1_start: "11:00", open1_end: "13:30",
27 }))
28 form.set(k, String(v));
29
30 let id = 0;
31 test("before the MLS description is approved, both pages say so and print no code", async () => {
32 const r = await call("/listings", { method: "POST", body: form });
33 id = Number(/\/listings\/(\d+)/.exec(r.headers.get("location")!)![1]);
34 for (const path of [`/listings/${id}/flyer`, `/listings/${id}/sign-in`]) {
35 const html = await (await call(path)).text();
36 expect(html).toContain("The property page is not public yet");
37 expect(html).not.toContain('class="qr"');
38 }
39 });
40
41 test("once approved, both carry the code, the label and the URL in words", async () => {
42 const page = await (await call(`/listings/${id}`)).text();
43 const seen = /name="seen" value="([^"]+)"/.exec(page)![1]!;
44 const done = await call(`/listings/${id}/assets/mls/approve`, {
45 method: "POST",
46 body: new URLSearchParams({ approved: "1", seen }),
47 headers: { "content-type": "application/x-www-form-urlencoded" },
48 });
49 expect(done.status).toBe(303);
50 const url = `${publicOrigin}/homes/${id}`;
51 const side = qrMatrix(url).length + 8;
52 for (const path of [`/listings/${id}/flyer`, `/listings/${id}/sign-in`]) {
53 const html = await (await call(path)).text();
54 expect(html).toContain('<svg class="qr"');
55 expect(html).toContain(`viewBox="0 0 ${side} ${side}"`);
56 expect(html).toContain("Scan to see the home");
57 expect(html).toContain(url);
58 expect(html).not.toContain("not public yet");
59 }
60 });
static/app.css +22 −1
@@ -1010,11 +1010,32 @@ main:focus {
10101010 margin-bottom: var(--space-4);
10111011 overflow-wrap: anywhere;
10121012 }
1013 .flyer-link {
1013 .scan {
10141014 flex-basis: 100%;
1015 display: flex;
1016 align-items: center;
1017 gap: var(--space-3);
10151018 font-size: var(--text-sm);
10161019 overflow-wrap: anywhere;
10171020 }
1021 .scan p {
1022 margin: 0;
1023 }
1024 .scan .qr {
1025 flex: none;
1026 width: 6rem;
1027 height: 6rem;
1028 }
1029 .scan-label {
1030 font-size: var(--text-base);
1031 }
1032 .signin .scan {
1033 margin: var(--space-3) 0;
1034 }
1035 .signin .scan .qr {
1036 width: 5rem;
1037 height: 5rem;
1038 }
10181039 .email-cta {
10191040 margin: var(--space-4) 0;
10201041 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs