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
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/83-print-a-qr-code-on-the-flyer-and-the-ope → main 3 commits fork
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. | ||
| 277 | 277 | viewer may list; `esc` goes back. |
| 278 | 278 | 5. **Print the flyer.** "Print flyer" opens a letter-size page — hero photo, |
| 279 | 279 | 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). | |
| 283 | 288 | |
| 284 | 289 | **Showings.** After each showing, write it down on the listing page: the day |
| 285 | 290 | (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. | ||
| 387 | 392 | | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` | |
| 388 | 393 | | `src/routes.ts` | every path, in one table, each API route described beside it | |
| 389 | 394 | | `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 | | |
| 390 | 396 | | `src/pages/` | the HTML, server-rendered: `layout`, home, listing and flyer, notes | |
| 391 | 397 | | `design/tokens.json`, `src/tokens.ts` | the look as data, light and dark; compiled to `/tokens.css` | |
| 392 | 398 | | `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"; | ||
| 27 | 27 | import { dayOf } from "../showings.ts"; |
| 28 | 28 | import { ago, layout, toast } from "./layout.ts"; |
| 29 | 29 | import { daysLineHtml, priceHistoryTable } from "./market.ts"; |
| 30 | import { scanBlock } from "./scan.ts"; | |
| 30 | 31 | import { showingsSection } from "./showings.ts"; |
| 31 | 32 | |
| 32 | 33 | /** 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 { | ||
| 330 | 331 | <p>${escape(l.agent_phone)} · ${escape(l.agent_email)}</p> |
| 331 | 332 | <p class="flyer-license">License ${escape(l.license)}</p> |
| 332 | 333 | </div> |
| 333 | <p class="flyer-link">See every photo: ${escape(propertyPath(l, origin))}</p> | |
| 334 | ${scanBlock(l, origin)} | |
| 334 | 335 | <p class="flyer-fine">Equal Housing Opportunity. Information provided by the listing agent; deemed reliable, not guaranteed.</p> |
| 335 | 336 | </footer> |
| 336 | 337 | </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"; | ||
| 9 | 9 | import type { Who } from "../who.ts"; |
| 10 | 10 | import { layout } from "./layout.ts"; |
| 11 | 11 | import { photoUrl } from "./listing.ts"; |
| 12 | import { scanBlock } from "./scan.ts"; | |
| 12 | 13 | |
| 13 | 14 | export const SIGN_IN_ROWS = 15; |
| 14 | 15 | |
| 15 | 16 | const blankRows = Array.from({ length: SIGN_IN_ROWS }, () => `<tr><td></td><td></td><td></td><td></td></tr>`).join("\n "); |
| 16 | 17 | |
| 17 | 18 | /** 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 { | |
| 19 | 20 | const { who, listing: l } = input; |
| 20 | 21 | const when = openHouseLine(l); |
| 21 | 22 | if (when === null) return null; |
| @@ -36,6 +37,7 @@ export function signInPage(input: { who: Who; listing: Launched }): string | nul | ||
| 36 | 37 | <p class="signin-agent"><b class="brand">${escape(l.agent_name)}</b> · ${escape(l.agent_phone)}</p> |
| 37 | 38 | </div> |
| 38 | 39 | </header> |
| 40 | ${scanBlock(l, input.origin ?? "")} | |
| 39 | 41 | <h3>Open house sign-in</h3> |
| 40 | 42 | <table class="signin-table"> |
| 41 | 43 | <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[] = [ | ||
| 641 | 641 | path: "/listings/:id/sign-in", |
| 642 | 642 | handle: (c) => { |
| 643 | 643 | 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; | |
| 645 | 645 | return page( |
| 646 | 646 | c.who, |
| 647 | 647 | 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 { | ||
| 1010 | 1010 | margin-bottom: var(); |
| 1011 | 1011 | overflow-wrap: anywhere; |
| 1012 | 1012 | } |
| 1013 | .flyer-link { | |
| 1013 | .scan { | |
| 1014 | 1014 | flex-basis: 100%; |
| 1015 | display: flex; | |
| 1016 | align-items: center; | |
| 1017 | gap: var(); | |
| 1015 | 1018 | font-size: var(); |
| 1016 | 1019 | overflow-wrap: anywhere; |
| 1017 | 1020 | } |
| 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(); | |
| 1031 | } | |
| 1032 | .signin .scan { | |
| 1033 | margin: var() 0; | |
| 1034 | } | |
| 1035 | .signin .scan .qr { | |
| 1036 | width: 5rem; | |
| 1037 | height: 5rem; | |
| 1038 | } | |
| 1018 | 1039 | .email-cta { |
| 1019 | 1040 | margin: var() 0; |
| 1020 | 1041 | } |