@stack-mode/site-manager-kit 1.44.0 → 1.44.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +606 -606
- package/audit/ts-hooks.mjs +68 -68
- package/audit/wiring-audit.d.mts +37 -37
- package/audit/wiring-audit.mjs +604 -604
- package/bin/site-manager-kit.mjs +10 -10
- package/package.json +72 -72
- package/src/announcements.ts +13 -13
- package/src/attribution-fields.ts +52 -52
- package/src/attribution.tsx +47 -47
- package/src/blog.ts +122 -122
- package/src/chat-route.ts +136 -136
- package/src/chat-types.ts +76 -76
- package/src/client.ts +116 -116
- package/src/config.ts +78 -78
- package/src/copy-keys-route.ts +21 -21
- package/src/copy-keys.ts +77 -77
- package/src/data.ts +189 -189
- package/src/downloads.ts +7 -7
- package/src/faq.ts +21 -21
- package/src/floor-plans.ts +45 -45
- package/src/form-guard.tsx +37 -37
- package/src/form-types.ts +13 -13
- package/src/forms.ts +76 -76
- package/src/glossary.ts +19 -19
- package/src/health-route.ts +34 -34
- package/src/index.ts +36 -36
- package/src/indexnow.ts +40 -40
- package/src/listings.ts +38 -38
- package/src/metadata.ts +55 -55
- package/src/not-found-reporter.tsx +16 -16
- package/src/not-found.ts +53 -53
- package/src/payment.ts +70 -70
- package/src/phone-input.tsx +26 -26
- package/src/phone.ts +37 -37
- package/src/preview-route.ts +48 -48
- package/src/preview.ts +94 -94
- package/src/residents.ts +23 -23
- package/src/revalidate-route.ts +34 -34
- package/src/rss-route.ts +26 -26
- package/src/services.ts +52 -52
- package/src/site-chat-panel.tsx +537 -537
- package/src/site-chat.tsx +26 -26
- package/src/sitemap.ts +26 -26
- package/src/structured-data.tsx +207 -207
- package/src/types.ts +580 -580
- package/src/version.ts +1 -1
- package/src/visual-accents.ts +126 -126
- package/src/visual-bridge-client.tsx +1070 -1070
- package/src/visual-bridge.tsx +30 -30
- package/src/visual-clicks.ts +13 -13
- package/src/visual-lists.ts +37 -37
- package/src/visual-match.ts +77 -77
- package/src/visual-messages.ts +160 -160
- package/src/visual-mode.ts +18 -18
- package/src/visual-preview.ts +69 -69
- package/src/visual-tags.ts +90 -90
- package/src/visual.ts +59 -59
- package/src/waitlist-form.ts +39 -39
- package/src/waitlist.ts +48 -48
package/src/listings.ts
CHANGED
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
import { formatMoney } from "./money";
|
|
2
|
-
import type { Listing } from "./types";
|
|
3
|
-
|
|
4
|
-
// Display text for listing facts. Each returns null when Site Manager doesn't know the value, so the page can
|
|
5
|
-
// leave the line out instead of showing a guess.
|
|
6
|
-
|
|
7
|
-
/** "2.5" stays "2.5"; "3.0" becomes "3". */
|
|
8
|
-
export function bathsText(baths: string | null): string | null {
|
|
9
|
-
if (baths === null) return null;
|
|
10
|
-
return baths.replace(/\.0+$/, "");
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/** "1.02 acres" or "8,276 sq ft". */
|
|
14
|
-
export function lotText(lot: Listing["lot"]): string | null {
|
|
15
|
-
if (!lot) return null;
|
|
16
|
-
const [whole, frac] = lot.size.split(".");
|
|
17
|
-
const trimmed = frac && Number(frac) !== 0 ? `${Number(whole).toLocaleString("en-US")}.${frac.replace(/0+$/, "")}` : Number(whole).toLocaleString("en-US");
|
|
18
|
-
return lot.unit === "acres" ? `${trimmed} ${trimmed === "1" ? "acre" : "acres"}` : `${trimmed} sq ft`;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** "$85 per month". */
|
|
22
|
-
export function hoaText(hoa: Listing["hoa"]): string | null {
|
|
23
|
-
if (!hoa) return null;
|
|
24
|
-
return `${formatMoney(hoa.amount)} per ${hoa.frequency === "monthly" ? "month" : hoa.frequency === "quarterly" ? "quarter" : "year"}`;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** "1180 Paddock Lane, Reno, NV 89521", skipping what isn't set. */
|
|
28
|
-
export function listingAddress(l: Pick<Listing, "address">): string {
|
|
29
|
-
const a = l.address;
|
|
30
|
-
const street = [a.line1, a.line2].filter(Boolean).join(" ");
|
|
31
|
-
const regionZip = [a.region, a.postalCode].filter(Boolean).join(" ");
|
|
32
|
-
return [street, a.city, regionZip].filter(Boolean).join(", ");
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** The price to headline: the sold price once sold, otherwise the list price. */
|
|
36
|
-
export function listingPrice(l: Pick<Listing, "status" | "price" | "soldPrice">): string | null {
|
|
37
|
-
return l.status === "sold" && l.soldPrice ? l.soldPrice : l.price;
|
|
38
|
-
}
|
|
1
|
+
import { formatMoney } from "./money";
|
|
2
|
+
import type { Listing } from "./types";
|
|
3
|
+
|
|
4
|
+
// Display text for listing facts. Each returns null when Site Manager doesn't know the value, so the page can
|
|
5
|
+
// leave the line out instead of showing a guess.
|
|
6
|
+
|
|
7
|
+
/** "2.5" stays "2.5"; "3.0" becomes "3". */
|
|
8
|
+
export function bathsText(baths: string | null): string | null {
|
|
9
|
+
if (baths === null) return null;
|
|
10
|
+
return baths.replace(/\.0+$/, "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** "1.02 acres" or "8,276 sq ft". */
|
|
14
|
+
export function lotText(lot: Listing["lot"]): string | null {
|
|
15
|
+
if (!lot) return null;
|
|
16
|
+
const [whole, frac] = lot.size.split(".");
|
|
17
|
+
const trimmed = frac && Number(frac) !== 0 ? `${Number(whole).toLocaleString("en-US")}.${frac.replace(/0+$/, "")}` : Number(whole).toLocaleString("en-US");
|
|
18
|
+
return lot.unit === "acres" ? `${trimmed} ${trimmed === "1" ? "acre" : "acres"}` : `${trimmed} sq ft`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** "$85 per month". */
|
|
22
|
+
export function hoaText(hoa: Listing["hoa"]): string | null {
|
|
23
|
+
if (!hoa) return null;
|
|
24
|
+
return `${formatMoney(hoa.amount)} per ${hoa.frequency === "monthly" ? "month" : hoa.frequency === "quarterly" ? "quarter" : "year"}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** "1180 Paddock Lane, Reno, NV 89521", skipping what isn't set. */
|
|
28
|
+
export function listingAddress(l: Pick<Listing, "address">): string {
|
|
29
|
+
const a = l.address;
|
|
30
|
+
const street = [a.line1, a.line2].filter(Boolean).join(" ");
|
|
31
|
+
const regionZip = [a.region, a.postalCode].filter(Boolean).join(" ");
|
|
32
|
+
return [street, a.city, regionZip].filter(Boolean).join(", ");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The price to headline: the sold price once sold, otherwise the list price. */
|
|
36
|
+
export function listingPrice(l: Pick<Listing, "status" | "price" | "soldPrice">): string | null {
|
|
37
|
+
return l.status === "sold" && l.soldPrice ? l.soldPrice : l.price;
|
|
38
|
+
}
|
package/src/metadata.ts
CHANGED
|
@@ -1,55 +1,55 @@
|
|
|
1
|
-
import type { Metadata } from "next";
|
|
2
|
-
import { bp, tagsFor } from "./client";
|
|
3
|
-
import { getSite } from "./data";
|
|
4
|
-
import type { PageMeta, Site } from "./types";
|
|
5
|
-
|
|
6
|
-
// Site Manager measures titles in pixels in its admin. The server can't, so the suffix rule here uses the
|
|
7
|
-
// character limit it matches: about 60 characters fit in 580px.
|
|
8
|
-
export const TITLE_CHAR_LIMIT = 60;
|
|
9
|
-
|
|
10
|
-
/** The title with the site's suffix when it still fits, otherwise the title alone. */
|
|
11
|
-
export function titleWithSuffix(title: string, suffix: string | null | undefined): string {
|
|
12
|
-
const t = title.trim();
|
|
13
|
-
if (!suffix) return t;
|
|
14
|
-
return `${t}${suffix}`.length <= TITLE_CHAR_LIMIT ? `${t}${suffix}` : t;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** Pure: Site Manager's page data to Next metadata. Anything set in `overrides` wins. */
|
|
18
|
-
export function buildMetadata(meta: PageMeta, overrides: Metadata = {}): Metadata {
|
|
19
|
-
const title = titleWithSuffix(meta.title, meta.titleSuffix);
|
|
20
|
-
const description = meta.description ?? undefined;
|
|
21
|
-
const images = meta.ogImage ? [{ url: meta.ogImage.url, width: meta.ogImage.width ?? undefined, height: meta.ogImage.height ?? undefined, alt: meta.ogImage.alt }] : undefined;
|
|
22
|
-
// Share previews use their own title and description when Site Manager has them.
|
|
23
|
-
const shareTitle = meta.ogTitle?.trim() || title;
|
|
24
|
-
const shareDescription = meta.ogDescription?.trim() || description;
|
|
25
|
-
return {
|
|
26
|
-
title: { absolute: title },
|
|
27
|
-
description,
|
|
28
|
-
alternates: meta.canonical ? { canonical: meta.canonical } : undefined,
|
|
29
|
-
robots: { index: !meta.noindex, follow: !meta.nofollow, ...(meta.maxImagePreview ? { "max-image-preview": meta.maxImagePreview } : {}) },
|
|
30
|
-
openGraph: { title: shareTitle, description: shareDescription, url: meta.canonical ?? undefined, siteName: meta.siteName, images, type: "website", locale: "en_US" },
|
|
31
|
-
// X reads Open Graph when its own tags are missing, so they're only written when the share text differs.
|
|
32
|
-
twitter: { card: "summary_large_image", ...(meta.ogTitle?.trim() ? { title: shareTitle } : {}), ...(meta.ogDescription?.trim() ? { description: shareDescription } : {}) },
|
|
33
|
-
...overrides,
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Pure: the fallback when Site Manager has no page for this path. */
|
|
38
|
-
export function defaultMetadata(site: Site | null, overrides: Metadata = {}): Metadata {
|
|
39
|
-
const name = site?.business.displayName ?? site?.name;
|
|
40
|
-
return { ...(name ? { title: { absolute: name }, openGraph: { siteName: name, type: "website", locale: "en_US" } } : {}), ...overrides };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Metadata for one path, for a page's `generateMetadata`. A path Site Manager doesn't know gets the site's
|
|
45
|
-
* defaults, and a Site Manager outage never breaks the page: it falls back to the overrides alone.
|
|
46
|
-
*/
|
|
47
|
-
export async function pageMetadata(path: string, overrides: Metadata = {}): Promise<Metadata> {
|
|
48
|
-
try {
|
|
49
|
-
const meta = await bp<PageMeta>(`/pages/meta?path=${encodeURIComponent(path)}`, { tags: tagsFor("seo") });
|
|
50
|
-
if (meta) return buildMetadata(meta, overrides);
|
|
51
|
-
return defaultMetadata(await getSite(), overrides);
|
|
52
|
-
} catch {
|
|
53
|
-
return overrides;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { bp, tagsFor } from "./client";
|
|
3
|
+
import { getSite } from "./data";
|
|
4
|
+
import type { PageMeta, Site } from "./types";
|
|
5
|
+
|
|
6
|
+
// Site Manager measures titles in pixels in its admin. The server can't, so the suffix rule here uses the
|
|
7
|
+
// character limit it matches: about 60 characters fit in 580px.
|
|
8
|
+
export const TITLE_CHAR_LIMIT = 60;
|
|
9
|
+
|
|
10
|
+
/** The title with the site's suffix when it still fits, otherwise the title alone. */
|
|
11
|
+
export function titleWithSuffix(title: string, suffix: string | null | undefined): string {
|
|
12
|
+
const t = title.trim();
|
|
13
|
+
if (!suffix) return t;
|
|
14
|
+
return `${t}${suffix}`.length <= TITLE_CHAR_LIMIT ? `${t}${suffix}` : t;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Pure: Site Manager's page data to Next metadata. Anything set in `overrides` wins. */
|
|
18
|
+
export function buildMetadata(meta: PageMeta, overrides: Metadata = {}): Metadata {
|
|
19
|
+
const title = titleWithSuffix(meta.title, meta.titleSuffix);
|
|
20
|
+
const description = meta.description ?? undefined;
|
|
21
|
+
const images = meta.ogImage ? [{ url: meta.ogImage.url, width: meta.ogImage.width ?? undefined, height: meta.ogImage.height ?? undefined, alt: meta.ogImage.alt }] : undefined;
|
|
22
|
+
// Share previews use their own title and description when Site Manager has them.
|
|
23
|
+
const shareTitle = meta.ogTitle?.trim() || title;
|
|
24
|
+
const shareDescription = meta.ogDescription?.trim() || description;
|
|
25
|
+
return {
|
|
26
|
+
title: { absolute: title },
|
|
27
|
+
description,
|
|
28
|
+
alternates: meta.canonical ? { canonical: meta.canonical } : undefined,
|
|
29
|
+
robots: { index: !meta.noindex, follow: !meta.nofollow, ...(meta.maxImagePreview ? { "max-image-preview": meta.maxImagePreview } : {}) },
|
|
30
|
+
openGraph: { title: shareTitle, description: shareDescription, url: meta.canonical ?? undefined, siteName: meta.siteName, images, type: "website", locale: "en_US" },
|
|
31
|
+
// X reads Open Graph when its own tags are missing, so they're only written when the share text differs.
|
|
32
|
+
twitter: { card: "summary_large_image", ...(meta.ogTitle?.trim() ? { title: shareTitle } : {}), ...(meta.ogDescription?.trim() ? { description: shareDescription } : {}) },
|
|
33
|
+
...overrides,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Pure: the fallback when Site Manager has no page for this path. */
|
|
38
|
+
export function defaultMetadata(site: Site | null, overrides: Metadata = {}): Metadata {
|
|
39
|
+
const name = site?.business.displayName ?? site?.name;
|
|
40
|
+
return { ...(name ? { title: { absolute: name }, openGraph: { siteName: name, type: "website", locale: "en_US" } } : {}), ...overrides };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Metadata for one path, for a page's `generateMetadata`. A path Site Manager doesn't know gets the site's
|
|
45
|
+
* defaults, and a Site Manager outage never breaks the page: it falls back to the overrides alone.
|
|
46
|
+
*/
|
|
47
|
+
export async function pageMetadata(path: string, overrides: Metadata = {}): Promise<Metadata> {
|
|
48
|
+
try {
|
|
49
|
+
const meta = await bp<PageMeta>(`/pages/meta?path=${encodeURIComponent(path)}`, { tags: tagsFor("seo") });
|
|
50
|
+
if (meta) return buildMetadata(meta, overrides);
|
|
51
|
+
return defaultMetadata(await getSite(), overrides);
|
|
52
|
+
} catch {
|
|
53
|
+
return overrides;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useEffect } from "react";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Put in the site's `app/not-found.tsx`: reports the missing address (and where the visitor came from) to the site's
|
|
7
|
-
* own `/api/not-found` route once, after the page shows. Renders nothing.
|
|
8
|
-
*/
|
|
9
|
-
export function NotFoundReporter({ endpoint = "/api/not-found" }: { endpoint?: string }) {
|
|
10
|
-
useEffect(() => {
|
|
11
|
-
const body = JSON.stringify({ path: window.location.pathname, referrer: document.referrer || null });
|
|
12
|
-
// keepalive lets the report finish if the visitor leaves straight away.
|
|
13
|
-
fetch(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body, keepalive: true }).catch(() => {});
|
|
14
|
-
}, [endpoint]);
|
|
15
|
-
return null;
|
|
16
|
-
}
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Put in the site's `app/not-found.tsx`: reports the missing address (and where the visitor came from) to the site's
|
|
7
|
+
* own `/api/not-found` route once, after the page shows. Renders nothing.
|
|
8
|
+
*/
|
|
9
|
+
export function NotFoundReporter({ endpoint = "/api/not-found" }: { endpoint?: string }) {
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const body = JSON.stringify({ path: window.location.pathname, referrer: document.referrer || null });
|
|
12
|
+
// keepalive lets the report finish if the visitor leaves straight away.
|
|
13
|
+
fetch(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body, keepalive: true }).catch(() => {});
|
|
14
|
+
}, [endpoint]);
|
|
15
|
+
return null;
|
|
16
|
+
}
|
package/src/not-found.ts
CHANGED
|
@@ -1,53 +1,53 @@
|
|
|
1
|
-
import { bp } from "./client";
|
|
2
|
-
|
|
3
|
-
// Missing-page reports for Site Manager's Not Found list (Search Listings, Redirects). The site's not-found page
|
|
4
|
-
// can't see the address it's showing on the server, so the browser tells the site's own route, which tells Site
|
|
5
|
-
// Manager with the site's key. Fire and forget: a failure never shows to the visitor.
|
|
6
|
-
|
|
7
|
-
const MAX = 500;
|
|
8
|
-
|
|
9
|
-
/** Reports one server instance passes on per minute; a flood past this is dropped before it reaches Site Manager. */
|
|
10
|
-
const PER_MINUTE = 120;
|
|
11
|
-
let window = { start: 0, count: 0 };
|
|
12
|
-
|
|
13
|
-
/** Whether one more report fits this minute (exported for tests). */
|
|
14
|
-
export function reportAllowed(now = Date.now()): boolean {
|
|
15
|
-
if (now - window.start >= 60_000) window = { start: now, count: 0 };
|
|
16
|
-
return ++window.count <= PER_MINUTE;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function resetReportLimit() {
|
|
20
|
-
window = { start: 0, count: 0 };
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Server only: reports one visit to an address the site doesn't have. Never throws. */
|
|
24
|
-
export async function reportNotFound(path: string, referrer?: string | null): Promise<void> {
|
|
25
|
-
if (typeof path !== "string" || !path.startsWith("/") || path.length > MAX) return;
|
|
26
|
-
await bp("/not-found", { method: "POST", body: { path, referrer: referrer ? String(referrer).slice(0, 2000) : null } }).catch(() => {});
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* The site's `app/api/not-found/route.ts`: `export { notFoundRoute as POST } from "site-manager-kit/not-found"`.
|
|
31
|
-
* Only this site's own pages may call it: browsers send Origin with every POST, so a call without one, or from
|
|
32
|
-
* another site, is dropped. At most 120 reports a minute per server instance. It always answers 204.
|
|
33
|
-
*/
|
|
34
|
-
export async function notFoundRoute(request: Request): Promise<Response> {
|
|
35
|
-
const done = new Response(null, { status: 204 });
|
|
36
|
-
const origin = request.headers.get("origin");
|
|
37
|
-
const host = request.headers.get("host");
|
|
38
|
-
try {
|
|
39
|
-
if (!origin || !host || new URL(origin).host !== host) return done;
|
|
40
|
-
} catch {
|
|
41
|
-
return done;
|
|
42
|
-
}
|
|
43
|
-
if (!reportAllowed()) return done;
|
|
44
|
-
const raw = await request.text().catch(() => "");
|
|
45
|
-
if (raw.length > 3000) return done;
|
|
46
|
-
try {
|
|
47
|
-
const body = JSON.parse(raw) as { path?: unknown; referrer?: unknown };
|
|
48
|
-
if (typeof body.path === "string") await reportNotFound(body.path, typeof body.referrer === "string" ? body.referrer : null);
|
|
49
|
-
} catch {
|
|
50
|
-
// Not JSON: nothing to report.
|
|
51
|
-
}
|
|
52
|
-
return done;
|
|
53
|
-
}
|
|
1
|
+
import { bp } from "./client";
|
|
2
|
+
|
|
3
|
+
// Missing-page reports for Site Manager's Not Found list (Search Listings, Redirects). The site's not-found page
|
|
4
|
+
// can't see the address it's showing on the server, so the browser tells the site's own route, which tells Site
|
|
5
|
+
// Manager with the site's key. Fire and forget: a failure never shows to the visitor.
|
|
6
|
+
|
|
7
|
+
const MAX = 500;
|
|
8
|
+
|
|
9
|
+
/** Reports one server instance passes on per minute; a flood past this is dropped before it reaches Site Manager. */
|
|
10
|
+
const PER_MINUTE = 120;
|
|
11
|
+
let window = { start: 0, count: 0 };
|
|
12
|
+
|
|
13
|
+
/** Whether one more report fits this minute (exported for tests). */
|
|
14
|
+
export function reportAllowed(now = Date.now()): boolean {
|
|
15
|
+
if (now - window.start >= 60_000) window = { start: now, count: 0 };
|
|
16
|
+
return ++window.count <= PER_MINUTE;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resetReportLimit() {
|
|
20
|
+
window = { start: 0, count: 0 };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Server only: reports one visit to an address the site doesn't have. Never throws. */
|
|
24
|
+
export async function reportNotFound(path: string, referrer?: string | null): Promise<void> {
|
|
25
|
+
if (typeof path !== "string" || !path.startsWith("/") || path.length > MAX) return;
|
|
26
|
+
await bp("/not-found", { method: "POST", body: { path, referrer: referrer ? String(referrer).slice(0, 2000) : null } }).catch(() => {});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The site's `app/api/not-found/route.ts`: `export { notFoundRoute as POST } from "site-manager-kit/not-found"`.
|
|
31
|
+
* Only this site's own pages may call it: browsers send Origin with every POST, so a call without one, or from
|
|
32
|
+
* another site, is dropped. At most 120 reports a minute per server instance. It always answers 204.
|
|
33
|
+
*/
|
|
34
|
+
export async function notFoundRoute(request: Request): Promise<Response> {
|
|
35
|
+
const done = new Response(null, { status: 204 });
|
|
36
|
+
const origin = request.headers.get("origin");
|
|
37
|
+
const host = request.headers.get("host");
|
|
38
|
+
try {
|
|
39
|
+
if (!origin || !host || new URL(origin).host !== host) return done;
|
|
40
|
+
} catch {
|
|
41
|
+
return done;
|
|
42
|
+
}
|
|
43
|
+
if (!reportAllowed()) return done;
|
|
44
|
+
const raw = await request.text().catch(() => "");
|
|
45
|
+
if (raw.length > 3000) return done;
|
|
46
|
+
try {
|
|
47
|
+
const body = JSON.parse(raw) as { path?: unknown; referrer?: unknown };
|
|
48
|
+
if (typeof body.path === "string") await reportNotFound(body.path, typeof body.referrer === "string" ? body.referrer : null);
|
|
49
|
+
} catch {
|
|
50
|
+
// Not JSON: nothing to report.
|
|
51
|
+
}
|
|
52
|
+
return done;
|
|
53
|
+
}
|
package/src/payment.ts
CHANGED
|
@@ -1,70 +1,70 @@
|
|
|
1
|
-
import type { HoaDues, PaymentDefaults, PaymentEstimate } from "./types";
|
|
2
|
-
|
|
3
|
-
/*
|
|
4
|
-
* Estimated monthly payment for a listing price, in integer math only (cents and BigInt fixed point), so no float
|
|
5
|
-
* rounding reaches an amount. Site Manager has the same function for its settings example (src/lib/payment.ts);
|
|
6
|
-
* both are tested against the same figures. Always label the result as an estimate on the page.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
const SCALE = BigInt(10) ** BigInt(18);
|
|
10
|
-
const MICRO = BigInt(1_000_000);
|
|
11
|
-
const HUNDRED = BigInt(100);
|
|
12
|
-
|
|
13
|
-
/** "1450", "$1,450.5" -> 145050 cents. Throws on anything that isn't an amount. */
|
|
14
|
-
export function toCents(value: string): bigint {
|
|
15
|
-
const m = value
|
|
16
|
-
.trim()
|
|
17
|
-
.replace(/^\$/, "")
|
|
18
|
-
.replace(/,/g, "")
|
|
19
|
-
.match(/^(\d{1,10})(?:\.(\d{1,2}))?$/);
|
|
20
|
-
if (!m) throw new Error(`Not an amount: ${value}`);
|
|
21
|
-
return BigInt(m[1]) * HUNDRED + BigInt((m[2] ?? "").padEnd(2, "0"));
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function fromCents(cents: bigint): string {
|
|
25
|
-
const negative = cents < BigInt(0);
|
|
26
|
-
const abs = negative ? -cents : cents;
|
|
27
|
-
return `${negative ? "-" : ""}${abs / HUNDRED}.${(abs % HUNDRED).toString().padStart(2, "0")}`;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** "6.75" -> 6750000 (millionths). */
|
|
31
|
-
function toMicro(value: string): bigint {
|
|
32
|
-
const m = value.trim().match(/^(\d{1,6})(?:\.(\d{1,6}))?$/);
|
|
33
|
-
if (!m) throw new Error(`Not a number: ${value}`);
|
|
34
|
-
return BigInt(m[1]) * MICRO + BigInt((m[2] ?? "").padEnd(6, "0"));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** a / b rounded half up, for non-negative values. */
|
|
38
|
-
const divRound = (a: bigint, b: bigint) => (a * BigInt(2) + b) / (BigInt(2) * b);
|
|
39
|
-
|
|
40
|
-
/** Monthly principal and interest in cents for a loan in cents. */
|
|
41
|
-
export function monthlyPrincipalAndInterest(loanCents: bigint, ratePercent: string, termYears: number): bigint {
|
|
42
|
-
const n = termYears * 12;
|
|
43
|
-
if (!Number.isInteger(n) || n <= 0) throw new Error("The term must be a whole number of years");
|
|
44
|
-
if (loanCents <= BigInt(0)) return BigInt(0);
|
|
45
|
-
const r = (toMicro(ratePercent) * SCALE) / (MICRO * BigInt(1200));
|
|
46
|
-
if (r === BigInt(0)) return divRound(loanCents, BigInt(n));
|
|
47
|
-
let pow = SCALE;
|
|
48
|
-
for (let i = 0; i < n; i++) pow = (pow * (SCALE + r)) / SCALE;
|
|
49
|
-
return divRound(loanCents * r * pow, (pow - SCALE) * SCALE);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* The estimated monthly payment for a price: principal and interest from the site's defaults, plus property tax,
|
|
54
|
-
* insurance and HOA dues when known (each null when not). Null when the price, rate, down payment or term isn't
|
|
55
|
-
* known, so the page shows no estimate rather than a made-up one.
|
|
56
|
-
*/
|
|
57
|
-
export function estimatePayment(price: string | null | undefined, d: PaymentDefaults, hoa: HoaDues | null = null): PaymentEstimate | null {
|
|
58
|
-
if (!price || d.ratePercent === null || d.downPaymentPercent === null || d.termYears === null) return null;
|
|
59
|
-
const priceCents = toCents(price);
|
|
60
|
-
if (priceCents <= BigInt(0)) return null;
|
|
61
|
-
const down = divRound(priceCents * toMicro(d.downPaymentPercent), MICRO * HUNDRED);
|
|
62
|
-
const loan = priceCents > down ? priceCents - down : BigInt(0);
|
|
63
|
-
const pi = monthlyPrincipalAndInterest(loan, d.ratePercent, d.termYears);
|
|
64
|
-
const tax = d.taxRatePercent === null ? null : divRound(priceCents * toMicro(d.taxRatePercent), MICRO * BigInt(1200));
|
|
65
|
-
const insurance = d.insuranceYearly === null ? null : divRound(toCents(d.insuranceYearly), BigInt(12));
|
|
66
|
-
const hoaMonthly = hoa === null ? null : divRound(toCents(hoa.amount), BigInt(hoa.frequency === "monthly" ? 1 : hoa.frequency === "quarterly" ? 3 : 12));
|
|
67
|
-
const total = [pi, tax, insurance, hoaMonthly].reduce<bigint>((sum, v) => sum + (v ?? BigInt(0)), BigInt(0));
|
|
68
|
-
const money = (c: bigint | null) => (c === null ? null : fromCents(c));
|
|
69
|
-
return { downPayment: fromCents(down), loanAmount: fromCents(loan), principalAndInterest: fromCents(pi), tax: money(tax), insurance: money(insurance), hoa: money(hoaMonthly), total: fromCents(total) };
|
|
70
|
-
}
|
|
1
|
+
import type { HoaDues, PaymentDefaults, PaymentEstimate } from "./types";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Estimated monthly payment for a listing price, in integer math only (cents and BigInt fixed point), so no float
|
|
5
|
+
* rounding reaches an amount. Site Manager has the same function for its settings example (src/lib/payment.ts);
|
|
6
|
+
* both are tested against the same figures. Always label the result as an estimate on the page.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const SCALE = BigInt(10) ** BigInt(18);
|
|
10
|
+
const MICRO = BigInt(1_000_000);
|
|
11
|
+
const HUNDRED = BigInt(100);
|
|
12
|
+
|
|
13
|
+
/** "1450", "$1,450.5" -> 145050 cents. Throws on anything that isn't an amount. */
|
|
14
|
+
export function toCents(value: string): bigint {
|
|
15
|
+
const m = value
|
|
16
|
+
.trim()
|
|
17
|
+
.replace(/^\$/, "")
|
|
18
|
+
.replace(/,/g, "")
|
|
19
|
+
.match(/^(\d{1,10})(?:\.(\d{1,2}))?$/);
|
|
20
|
+
if (!m) throw new Error(`Not an amount: ${value}`);
|
|
21
|
+
return BigInt(m[1]) * HUNDRED + BigInt((m[2] ?? "").padEnd(2, "0"));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function fromCents(cents: bigint): string {
|
|
25
|
+
const negative = cents < BigInt(0);
|
|
26
|
+
const abs = negative ? -cents : cents;
|
|
27
|
+
return `${negative ? "-" : ""}${abs / HUNDRED}.${(abs % HUNDRED).toString().padStart(2, "0")}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** "6.75" -> 6750000 (millionths). */
|
|
31
|
+
function toMicro(value: string): bigint {
|
|
32
|
+
const m = value.trim().match(/^(\d{1,6})(?:\.(\d{1,6}))?$/);
|
|
33
|
+
if (!m) throw new Error(`Not a number: ${value}`);
|
|
34
|
+
return BigInt(m[1]) * MICRO + BigInt((m[2] ?? "").padEnd(6, "0"));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** a / b rounded half up, for non-negative values. */
|
|
38
|
+
const divRound = (a: bigint, b: bigint) => (a * BigInt(2) + b) / (BigInt(2) * b);
|
|
39
|
+
|
|
40
|
+
/** Monthly principal and interest in cents for a loan in cents. */
|
|
41
|
+
export function monthlyPrincipalAndInterest(loanCents: bigint, ratePercent: string, termYears: number): bigint {
|
|
42
|
+
const n = termYears * 12;
|
|
43
|
+
if (!Number.isInteger(n) || n <= 0) throw new Error("The term must be a whole number of years");
|
|
44
|
+
if (loanCents <= BigInt(0)) return BigInt(0);
|
|
45
|
+
const r = (toMicro(ratePercent) * SCALE) / (MICRO * BigInt(1200));
|
|
46
|
+
if (r === BigInt(0)) return divRound(loanCents, BigInt(n));
|
|
47
|
+
let pow = SCALE;
|
|
48
|
+
for (let i = 0; i < n; i++) pow = (pow * (SCALE + r)) / SCALE;
|
|
49
|
+
return divRound(loanCents * r * pow, (pow - SCALE) * SCALE);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The estimated monthly payment for a price: principal and interest from the site's defaults, plus property tax,
|
|
54
|
+
* insurance and HOA dues when known (each null when not). Null when the price, rate, down payment or term isn't
|
|
55
|
+
* known, so the page shows no estimate rather than a made-up one.
|
|
56
|
+
*/
|
|
57
|
+
export function estimatePayment(price: string | null | undefined, d: PaymentDefaults, hoa: HoaDues | null = null): PaymentEstimate | null {
|
|
58
|
+
if (!price || d.ratePercent === null || d.downPaymentPercent === null || d.termYears === null) return null;
|
|
59
|
+
const priceCents = toCents(price);
|
|
60
|
+
if (priceCents <= BigInt(0)) return null;
|
|
61
|
+
const down = divRound(priceCents * toMicro(d.downPaymentPercent), MICRO * HUNDRED);
|
|
62
|
+
const loan = priceCents > down ? priceCents - down : BigInt(0);
|
|
63
|
+
const pi = monthlyPrincipalAndInterest(loan, d.ratePercent, d.termYears);
|
|
64
|
+
const tax = d.taxRatePercent === null ? null : divRound(priceCents * toMicro(d.taxRatePercent), MICRO * BigInt(1200));
|
|
65
|
+
const insurance = d.insuranceYearly === null ? null : divRound(toCents(d.insuranceYearly), BigInt(12));
|
|
66
|
+
const hoaMonthly = hoa === null ? null : divRound(toCents(hoa.amount), BigInt(hoa.frequency === "monthly" ? 1 : hoa.frequency === "quarterly" ? 3 : 12));
|
|
67
|
+
const total = [pi, tax, insurance, hoaMonthly].reduce<bigint>((sum, v) => sum + (v ?? BigInt(0)), BigInt(0));
|
|
68
|
+
const money = (c: bigint | null) => (c === null ? null : fromCents(c));
|
|
69
|
+
return { downPayment: fromCents(down), loanAmount: fromCents(loan), principalAndInterest: fromCents(pi), tax: money(tax), insurance: money(insurance), hoa: money(hoaMonthly), total: fromCents(total) };
|
|
70
|
+
}
|
package/src/phone-input.tsx
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState, type InputHTMLAttributes } from "react";
|
|
4
|
-
import { maskPhoneInput } from "./phone";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* A phone field that formats as the visitor types: (555) 123-4567. Use it for a form's phone fields; Site Manager
|
|
8
|
-
* checks for 10 US digits and stores that same format. Everything else (name, required, className) passes through.
|
|
9
|
-
*/
|
|
10
|
-
export function PhoneInput({ defaultValue, onChange, ...rest }: Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value"> & { defaultValue?: string }) {
|
|
11
|
-
const [value, setValue] = useState(defaultValue ? maskPhoneInput("", String(defaultValue)) : "");
|
|
12
|
-
return (
|
|
13
|
-
<input
|
|
14
|
-
{...rest}
|
|
15
|
-
type="tel"
|
|
16
|
-
inputMode="tel"
|
|
17
|
-
autoComplete={rest.autoComplete ?? "tel"}
|
|
18
|
-
placeholder={rest.placeholder ?? "(555) 123-4567"}
|
|
19
|
-
value={value}
|
|
20
|
-
onChange={(e) => {
|
|
21
|
-
setValue(maskPhoneInput(value, e.target.value));
|
|
22
|
-
onChange?.(e);
|
|
23
|
-
}}
|
|
24
|
-
/>
|
|
25
|
-
);
|
|
26
|
-
}
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type InputHTMLAttributes } from "react";
|
|
4
|
+
import { maskPhoneInput } from "./phone";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A phone field that formats as the visitor types: (555) 123-4567. Use it for a form's phone fields; Site Manager
|
|
8
|
+
* checks for 10 US digits and stores that same format. Everything else (name, required, className) passes through.
|
|
9
|
+
*/
|
|
10
|
+
export function PhoneInput({ defaultValue, onChange, ...rest }: Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value"> & { defaultValue?: string }) {
|
|
11
|
+
const [value, setValue] = useState(defaultValue ? maskPhoneInput("", String(defaultValue)) : "");
|
|
12
|
+
return (
|
|
13
|
+
<input
|
|
14
|
+
{...rest}
|
|
15
|
+
type="tel"
|
|
16
|
+
inputMode="tel"
|
|
17
|
+
autoComplete={rest.autoComplete ?? "tel"}
|
|
18
|
+
placeholder={rest.placeholder ?? "(555) 123-4567"}
|
|
19
|
+
value={value}
|
|
20
|
+
onChange={(e) => {
|
|
21
|
+
setValue(maskPhoneInput(value, e.target.value));
|
|
22
|
+
onChange?.(e);
|
|
23
|
+
}}
|
|
24
|
+
/>
|
|
25
|
+
);
|
|
26
|
+
}
|
package/src/phone.ts
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
// US phone numbers, one format everywhere: (555) 123-4567. The same rules as Site Manager's own (src/lib/phone.ts
|
|
2
|
-
// there), so what a visitor types, what Site Manager stores and what it shows all match.
|
|
3
|
-
|
|
4
|
-
/** The 10 digits of a US number, dropping a leading country code 1. Extra digits beyond 10 are ignored. */
|
|
5
|
-
export function phoneDigits(raw: string): string {
|
|
6
|
-
let d = raw.replace(/\D/g, "");
|
|
7
|
-
if (d.length > 10 && d.startsWith("1")) d = d.slice(1);
|
|
8
|
-
return d.slice(0, 10);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/** As typed: "(555) 123-4567", built up as the digits come in. `complete` once there are 10. */
|
|
12
|
-
export function formatUsPhone(raw: string): { text: string; complete: boolean } {
|
|
13
|
-
const d = phoneDigits(raw);
|
|
14
|
-
let text = "";
|
|
15
|
-
if (d.length > 0) text = `(${d.slice(0, 3)}`;
|
|
16
|
-
if (d.length >= 3) text += ")";
|
|
17
|
-
if (d.length > 3) text += ` ${d.slice(3, 6)}`;
|
|
18
|
-
if (d.length > 6) text += `-${d.slice(6)}`;
|
|
19
|
-
return { text, complete: d.length === 10 };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* The next value of a masked input. Backspace over a ")" or "-" removes the digit before it too, so the mask never
|
|
24
|
-
* traps the cursor.
|
|
25
|
-
*/
|
|
26
|
-
export function maskPhoneInput(prev: string, next: string): string {
|
|
27
|
-
if (next.length < prev.length && phoneDigits(next) === phoneDigits(prev)) return formatUsPhone(phoneDigits(prev).slice(0, -1)).text;
|
|
28
|
-
return formatUsPhone(next).text;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** The stored form of a phone number, or null when it isn't a 10-digit US number. */
|
|
32
|
-
export function normalizeUsPhone(raw: string): string | null {
|
|
33
|
-
const f = formatUsPhone(raw);
|
|
34
|
-
return f.complete && raw.replace(/\D/g, "").length <= 11 ? f.text : null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export const PHONE_ERROR = "Enter a 10-digit phone number";
|
|
1
|
+
// US phone numbers, one format everywhere: (555) 123-4567. The same rules as Site Manager's own (src/lib/phone.ts
|
|
2
|
+
// there), so what a visitor types, what Site Manager stores and what it shows all match.
|
|
3
|
+
|
|
4
|
+
/** The 10 digits of a US number, dropping a leading country code 1. Extra digits beyond 10 are ignored. */
|
|
5
|
+
export function phoneDigits(raw: string): string {
|
|
6
|
+
let d = raw.replace(/\D/g, "");
|
|
7
|
+
if (d.length > 10 && d.startsWith("1")) d = d.slice(1);
|
|
8
|
+
return d.slice(0, 10);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** As typed: "(555) 123-4567", built up as the digits come in. `complete` once there are 10. */
|
|
12
|
+
export function formatUsPhone(raw: string): { text: string; complete: boolean } {
|
|
13
|
+
const d = phoneDigits(raw);
|
|
14
|
+
let text = "";
|
|
15
|
+
if (d.length > 0) text = `(${d.slice(0, 3)}`;
|
|
16
|
+
if (d.length >= 3) text += ")";
|
|
17
|
+
if (d.length > 3) text += ` ${d.slice(3, 6)}`;
|
|
18
|
+
if (d.length > 6) text += `-${d.slice(6)}`;
|
|
19
|
+
return { text, complete: d.length === 10 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The next value of a masked input. Backspace over a ")" or "-" removes the digit before it too, so the mask never
|
|
24
|
+
* traps the cursor.
|
|
25
|
+
*/
|
|
26
|
+
export function maskPhoneInput(prev: string, next: string): string {
|
|
27
|
+
if (next.length < prev.length && phoneDigits(next) === phoneDigits(prev)) return formatUsPhone(phoneDigits(prev).slice(0, -1)).text;
|
|
28
|
+
return formatUsPhone(next).text;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The stored form of a phone number, or null when it isn't a 10-digit US number. */
|
|
32
|
+
export function normalizeUsPhone(raw: string): string | null {
|
|
33
|
+
const f = formatUsPhone(raw);
|
|
34
|
+
return f.complete && raw.replace(/\D/g, "").length <= 11 ? f.text : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const PHONE_ERROR = "Enter a 10-digit phone number";
|