@stack-mode/site-manager-kit 0.0.0-stage → 1.44.0
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 -3
- package/audit/ts-hooks.mjs +68 -0
- package/audit/wiring-audit.d.mts +37 -0
- package/audit/wiring-audit.mjs +604 -0
- package/bin/site-manager-kit.mjs +10 -0
- package/package.json +72 -6
- package/src/analytics.tsx +23 -0
- package/src/announcements.ts +13 -0
- package/src/attribution-fields.ts +52 -0
- package/src/attribution.tsx +47 -0
- package/src/blog.ts +122 -0
- package/src/chat-route.ts +136 -0
- package/src/chat-types.ts +76 -0
- package/src/client.ts +116 -0
- package/src/config.ts +78 -0
- package/src/copy-keys-route.ts +21 -0
- package/src/copy-keys.ts +77 -0
- package/src/data.ts +189 -0
- package/src/downloads.ts +7 -0
- package/src/faq.ts +21 -0
- package/src/floor-plans.ts +45 -0
- package/src/form-guard.tsx +37 -0
- package/src/form-token.ts +33 -0
- package/src/form-types.ts +13 -0
- package/src/forms.ts +76 -0
- package/src/glossary.ts +19 -0
- package/src/health-route.ts +34 -0
- package/src/image.ts +12 -0
- package/src/index.ts +36 -0
- package/src/indexnow.ts +40 -0
- package/src/listings.ts +38 -0
- package/src/metadata.ts +55 -0
- package/src/money.ts +10 -0
- package/src/not-found-reporter.tsx +16 -0
- package/src/not-found.ts +53 -0
- package/src/payment.ts +70 -0
- package/src/phone-input.tsx +26 -0
- package/src/phone.ts +37 -0
- package/src/preview-banner.tsx +49 -0
- package/src/preview-route.ts +48 -0
- package/src/preview.ts +94 -0
- package/src/redirects.ts +57 -0
- package/src/residents.ts +23 -0
- package/src/revalidate-route.ts +34 -0
- package/src/robots.ts +26 -0
- package/src/rss-route.ts +26 -0
- package/src/services.ts +52 -0
- package/src/signature.ts +23 -0
- package/src/site-chat-panel.tsx +537 -0
- package/src/site-chat.tsx +26 -0
- package/src/sitemap.ts +26 -0
- package/src/structured-data.tsx +207 -0
- package/src/types.ts +580 -0
- package/src/version.ts +1 -0
- package/src/visual-accents.ts +126 -0
- package/src/visual-bridge-client.tsx +1070 -0
- package/src/visual-bridge.tsx +30 -0
- package/src/visual-clicks.ts +13 -0
- package/src/visual-lists.ts +37 -0
- package/src/visual-match.ts +77 -0
- package/src/visual-messages.ts +160 -0
- package/src/visual-mode.ts +18 -0
- package/src/visual-preview.ts +69 -0
- package/src/visual-tags.ts +90 -0
- package/src/visual.ts +59 -0
- package/src/waitlist-form.ts +39 -0
- package/src/waitlist.ts +48 -0
package/src/forms.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { headers } from "next/headers";
|
|
5
|
+
import { bpRaw } from "./client";
|
|
6
|
+
import { checkFormToken } from "./form-token";
|
|
7
|
+
import { ATTRIBUTION_FIELD_NAMES, attributionFrom } from "./attribution-fields";
|
|
8
|
+
import { SLOT_FIELD, type SpamReason, type SubmitResult } from "./form-types";
|
|
9
|
+
|
|
10
|
+
const GUARD_FIELDS = new Set(["bp_hp", "bp_ts"]);
|
|
11
|
+
|
|
12
|
+
async function isBot(): Promise<boolean> {
|
|
13
|
+
try {
|
|
14
|
+
const { checkBotId } = await import("botid/server");
|
|
15
|
+
const r = await checkBotId();
|
|
16
|
+
return r.isBot && !r.isVerifiedBot;
|
|
17
|
+
} catch {
|
|
18
|
+
// BotID isn't set up on this site. The honeypot and time check still apply.
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type SubmitOptions = {
|
|
24
|
+
/**
|
|
25
|
+
* The site checked how long the form was open itself (its own signed timer), or the send has no form at all (a
|
|
26
|
+
* remembered visitor's one-click download), so there's no FormGuard timer to check. The honeypot (when sent) and
|
|
27
|
+
* BotID still apply. Use only after the site's own checks; anything else should render FormGuard.
|
|
28
|
+
*/
|
|
29
|
+
timedBySite?: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Sends one form to Site Manager from the site's server. Checks BotID, the honeypot and the 3-second render
|
|
34
|
+
* time first, hashes the visitor's IP (Site Manager never sees it), then forwards the fields.
|
|
35
|
+
*
|
|
36
|
+
* It returns the result, so use it from server code, or use formAction with useActionState in a client form.
|
|
37
|
+
*/
|
|
38
|
+
export async function submitForm(formKey: string, formData: FormData, opts: SubmitOptions = {}): Promise<SubmitResult> {
|
|
39
|
+
// Spam checks: a filled honeypot, a form sent too fast (or with a missing or stale timer), or BotID. Suspected
|
|
40
|
+
// spam still goes to Site Manager, marked, for its spam folder (where a real person caught by mistake can be
|
|
41
|
+
// rescued), and the visitor sees the usual thank-you, so bots learn nothing.
|
|
42
|
+
const token = opts.timedBySite ? { ok: true as const } : checkFormToken(String(formData.get("bp_ts") ?? ""));
|
|
43
|
+
const spam: SpamReason | null = String(formData.get("bp_hp") ?? "").trim() !== "" ? "honeypot" : !token.ok ? (token.reason === "too_fast" ? "too_fast" : "bot") : (await isBot()) ? "bot" : null;
|
|
44
|
+
|
|
45
|
+
const h = await headers();
|
|
46
|
+
const ip = h.get("x-real-ip") ?? h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "";
|
|
47
|
+
const data: Record<string, string> = {};
|
|
48
|
+
for (const [k, v] of formData.entries()) {
|
|
49
|
+
if (GUARD_FIELDS.has(k) || ATTRIBUTION_FIELD_NAMES.has(k) || k === SLOT_FIELD || k.startsWith("$ACTION") || typeof v !== "string") continue;
|
|
50
|
+
data[k] = v;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { status, json } = await bpRaw(`/forms/${encodeURIComponent(formKey)}/submit`, {
|
|
54
|
+
data,
|
|
55
|
+
pageUrl: h.get("referer") ?? undefined,
|
|
56
|
+
ipHash: ip ? createHash("sha256").update(ip).digest("hex") : undefined,
|
|
57
|
+
userAgent: h.get("user-agent")?.slice(0, 500) ?? undefined,
|
|
58
|
+
attribution: attributionFrom(formData),
|
|
59
|
+
slot: typeof formData.get(SLOT_FIELD) === "string" && formData.get(SLOT_FIELD) ? String(formData.get(SLOT_FIELD)) : undefined,
|
|
60
|
+
// A Site Manager from before the spam folder ignores `spam` but still refuses a filled honeypot, so spam is
|
|
61
|
+
// never delivered as a lead there either.
|
|
62
|
+
...(spam ? { spam, hp: `kit:${spam}` } : {}),
|
|
63
|
+
});
|
|
64
|
+
const body = json as { data?: { id: string; successMessage: string }; error?: { code: string; fields?: Record<string, string> } } | null;
|
|
65
|
+
if (spam) return { ok: true, id: body?.data?.id ?? "", successMessage: body?.data?.successMessage ?? "Thanks! We'll be in touch soon." };
|
|
66
|
+
if (status === 201 && body?.data) return { ok: true, id: body.data.id, successMessage: body.data.successMessage };
|
|
67
|
+
if (status === 422) return { ok: false, error: "invalid", fields: body?.error?.fields ?? {} };
|
|
68
|
+
if (status === 409) return { ok: false, error: "slot_taken", fields: body?.error?.fields ?? {} };
|
|
69
|
+
if (status === 429) return { ok: false, error: "rate_limited" };
|
|
70
|
+
return { ok: false, error: "unavailable" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The same, shaped for React's useActionState: `useActionState(formAction.bind(null, "inquiry"), null)`. */
|
|
74
|
+
export async function formAction(formKey: string, _prev: SubmitResult | null, formData: FormData): Promise<SubmitResult> {
|
|
75
|
+
return submitForm(formKey, formData);
|
|
76
|
+
}
|
package/src/glossary.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { GlossaryTerm } from "./types";
|
|
2
|
+
|
|
3
|
+
type Json = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* schema.org `DefinedTerm` structured data for a term's own page, in the glossary named `setName` at `setUrl`.
|
|
7
|
+
* Render it with `jsonLdScript`.
|
|
8
|
+
*/
|
|
9
|
+
export function definedTermJsonLd(t: Pick<GlossaryTerm, "term" | "shortDefinition" | "url" | "alsoKnownAs">, set: { setName: string; setUrl?: string | null }): Json {
|
|
10
|
+
return {
|
|
11
|
+
"@context": "https://schema.org",
|
|
12
|
+
"@type": "DefinedTerm",
|
|
13
|
+
name: t.term,
|
|
14
|
+
description: t.shortDefinition,
|
|
15
|
+
...(t.url ? { url: t.url } : {}),
|
|
16
|
+
...(t.alsoKnownAs.length ? { alternateName: t.alsoKnownAs } : {}),
|
|
17
|
+
inDefinedTermSet: { "@type": "DefinedTermSet", name: set.setName, ...(set.setUrl ? { url: set.setUrl } : {}) },
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { SIGNATURE_HEADER, verify } from "./signature";
|
|
2
|
+
import { KIT_VERSION } from "./version";
|
|
3
|
+
|
|
4
|
+
async function botIdInstalled(): Promise<boolean> {
|
|
5
|
+
try {
|
|
6
|
+
await import("botid/server");
|
|
7
|
+
return true;
|
|
8
|
+
} catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The deployment serving this site (Vercel sets these), so Site Manager can tell a new deploy happened. */
|
|
14
|
+
function deployId(): string | null {
|
|
15
|
+
return process.env.VERCEL_DEPLOYMENT_ID?.trim() || process.env.VERCEL_GIT_COMMIT_SHA?.trim() || null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Site Manager calls this to show Form protection and Site connection in its System Status. Put in
|
|
20
|
+
* app/api/blueprint/health/route.ts:
|
|
21
|
+
*
|
|
22
|
+
* export { GET } from "site-manager-kit/health-route";
|
|
23
|
+
*
|
|
24
|
+
* Signed with the revalidate secret over an empty body. Unsigned requests get a 401. `deployId` changing tells Site
|
|
25
|
+
* Manager the site was deployed, so it runs its wiring check the same day.
|
|
26
|
+
*/
|
|
27
|
+
export async function GET(req: Request): Promise<Response> {
|
|
28
|
+
const check = verify(process.env.BLUEPRINT_REVALIDATE_SECRET, req.headers.get(SIGNATURE_HEADER), "");
|
|
29
|
+
if (!check.ok) return Response.json({ error: check.reason }, { status: 401 });
|
|
30
|
+
return Response.json(
|
|
31
|
+
{ kitVersion: KIT_VERSION, botId: await botIdInstalled(), formSecret: !!process.env.BLUEPRINT_FORM_SECRET?.trim(), revalidate: true, deployId: deployId() },
|
|
32
|
+
{ headers: { "cache-control": "no-store" } },
|
|
33
|
+
);
|
|
34
|
+
}
|
package/src/image.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Image } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Props for next/image from a Site Manager image: `<Image {...imageProps(plan.image)} sizes="..." />`.
|
|
5
|
+
* With the size known it gets width and height, so the page doesn't jump while it loads; without, it uses `fill`,
|
|
6
|
+
* so put it in a sized, positioned box. Null when there's no image, so the caller decides what to show instead.
|
|
7
|
+
*/
|
|
8
|
+
export function imageProps(image: Image | null | undefined): ({ src: string; alt: string } & ({ width: number; height: number } | { fill: true })) | null {
|
|
9
|
+
if (!image?.url) return null;
|
|
10
|
+
if (image.width && image.height) return { src: image.url, alt: image.alt, width: image.width, height: image.height };
|
|
11
|
+
return { src: image.url, alt: image.alt, fill: true };
|
|
12
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Server-side helpers for a Next.js site that reads its content from Site Manager.
|
|
2
|
+
// Route handlers, the form action and the form guard have their own entry points:
|
|
3
|
+
// site-manager-kit/revalidate-route, site-manager-kit/health-route, site-manager-kit/forms, site-manager-kit/waitlist,
|
|
4
|
+
// site-manager-kit/form-guard, site-manager-kit/redirects, site-manager-kit/preview-route, site-manager-kit/rss-route,
|
|
5
|
+
// site-manager-kit/visual, site-manager-kit/copy-keys-route, site-manager-kit/chat-route. A site's src/copy-keys.ts uses site-manager-kit/copy-keys.
|
|
6
|
+
// next.config.ts uses
|
|
7
|
+
// site-manager-kit/config, and the root layout site-manager-kit/analytics, site-manager-kit/attribution and
|
|
8
|
+
// site-manager-kit/preview-banner, site-manager-kit/visual-editing and site-manager-kit/chat.
|
|
9
|
+
|
|
10
|
+
export { bp, bpWithMeta, BlueprintError, siteId, tagsFor } from "./client";
|
|
11
|
+
export * from "./data";
|
|
12
|
+
export { blogPostingJsonLd, buildRss, getBlogCategories, getBlogCategory, getBlogFeed, getBlogTags, getPost, getPosts } from "./blog";
|
|
13
|
+
export { buildMetadata, defaultMetadata, pageMetadata, titleWithSuffix, TITLE_CHAR_LIMIT } from "./metadata";
|
|
14
|
+
export { buildSitemap, sitemap } from "./sitemap";
|
|
15
|
+
export { buildRobots, robots } from "./robots";
|
|
16
|
+
export { buildRedirectMap, handleRedirect, matchRedirect, normalizePath } from "./redirects";
|
|
17
|
+
export { formatMoney } from "./money";
|
|
18
|
+
export { availabilityText, comparisonRows, effectiveRentText, type ComparisonRow } from "./floor-plans";
|
|
19
|
+
export { bathsText, hoaText, listingAddress, listingPrice, lotText } from "./listings";
|
|
20
|
+
export { estimatePayment } from "./payment";
|
|
21
|
+
export { serviceJsonLd } from "./services";
|
|
22
|
+
export { leaseTermsText, pickupText } from "./residents";
|
|
23
|
+
export { faqJsonLd, jsonLdScript } from "./faq";
|
|
24
|
+
export { absoluteUrl, breadcrumbJsonLd, breadcrumbsFor, businessJsonLd, eventJsonLd, floorPlanJsonLd, listingJsonLd, openingHours, placeMapUrl, plainText, StructuredData } from "./structured-data";
|
|
25
|
+
export { definedTermJsonLd } from "./glossary";
|
|
26
|
+
export { announcementsLiveAt } from "./announcements";
|
|
27
|
+
export { fileSizeText } from "./downloads";
|
|
28
|
+
export { imageProps } from "./image";
|
|
29
|
+
export { sign, verify, SIGNATURE_HEADER } from "./signature";
|
|
30
|
+
export { SLOT_FIELD, type SubmitResult } from "./form-types";
|
|
31
|
+
export { readWaitlistForm, waitlistChoices, WAITLIST_FIELDS, type WaitlistInput } from "./waitlist-form";
|
|
32
|
+
export { formatUsPhone, maskPhoneInput, normalizeUsPhone, phoneDigits } from "./phone";
|
|
33
|
+
export { attributionFrom, firstTouch, ATTRIBUTION_FIELDS } from "./attribution-fields";
|
|
34
|
+
export type * from "./types";
|
|
35
|
+
export { parseChatLines, type ChatConfig, type ChatEvent, type ChatSlots } from "./chat-types";
|
|
36
|
+
export { KIT_VERSION } from "./version";
|
package/src/indexnow.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
2
|
+
import { bp } from "./client";
|
|
3
|
+
|
|
4
|
+
// IndexNow: Site Manager tells search engines (Bing and others; not Google) when pages change, and they check the
|
|
5
|
+
// site serves the same key at /{key}.txt. The key comes from Site Manager, kept in memory for an hour.
|
|
6
|
+
|
|
7
|
+
const TTL_MS = 60 * 60_000;
|
|
8
|
+
let cache: { at: number; key: string | null } | null = null;
|
|
9
|
+
|
|
10
|
+
/** The IndexNow key Site Manager uses for this site, or null when IndexNow isn't set up. */
|
|
11
|
+
export async function indexNowKey(): Promise<string | null> {
|
|
12
|
+
if (cache && Date.now() - cache.at < TTL_MS) return cache.key;
|
|
13
|
+
try {
|
|
14
|
+
const r = await bp<{ key: string | null }>("/indexnow", { cache: "no-store" });
|
|
15
|
+
cache = { at: Date.now(), key: r?.key ?? null };
|
|
16
|
+
} catch {
|
|
17
|
+
// Site Manager unreachable: don't remember the failure, try again next time.
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
return cache.key;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Clears the remembered key (tests, or after Site Manager's key changes). */
|
|
24
|
+
export function resetIndexNowKey() {
|
|
25
|
+
cache = null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const KEY_FILE = /^\/([A-Za-z0-9-]{8,128})\.txt$/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* For `src/proxy.ts`: answers `/{key}.txt` with the key when it matches Site Manager's, or undefined to carry on.
|
|
32
|
+
* Any other .txt address passes through untouched.
|
|
33
|
+
*/
|
|
34
|
+
export async function handleIndexNow(request: NextRequest): Promise<NextResponse | undefined> {
|
|
35
|
+
const m = KEY_FILE.exec(request.nextUrl.pathname);
|
|
36
|
+
if (!m) return undefined;
|
|
37
|
+
const key = await indexNowKey();
|
|
38
|
+
if (!key || key !== m[1]) return undefined;
|
|
39
|
+
return new NextResponse(key, { headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "public, max-age=3600" } });
|
|
40
|
+
}
|
package/src/listings.ts
ADDED
|
@@ -0,0 +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
|
+
}
|
package/src/metadata.ts
ADDED
|
@@ -0,0 +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
|
+
}
|
package/src/money.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** "$1,450" or "$1,450.00" from Site Manager's decimal strings. No float math on the amount. */
|
|
2
|
+
export function formatMoney(value: string | null | undefined, opts: { cents?: boolean } = {}): string | null {
|
|
3
|
+
if (value === null || value === undefined) return null;
|
|
4
|
+
const m = value.trim().match(/^(-?)(\d+)(?:\.(\d{1,2}))?$/);
|
|
5
|
+
if (!m) return null;
|
|
6
|
+
const [, sign, whole, frac = "00"] = m;
|
|
7
|
+
const cents = frac.padEnd(2, "0");
|
|
8
|
+
const grouped = whole.replace(/^0+(?=\d)/, "").replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
9
|
+
return `${sign}$${grouped}${opts.cents || cents !== "00" ? `.${cents}` : ""}`;
|
|
10
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
package/src/not-found.ts
ADDED
|
@@ -0,0 +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
|
+
}
|
package/src/payment.ts
ADDED
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
package/src/phone.ts
ADDED
|
@@ -0,0 +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";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { draftMode } from "next/headers";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Put once inside <body> in app/layout.tsx. While a preview is open it shows a small bar saying so, with a way out;
|
|
5
|
+
* otherwise it renders nothing. It uses the browser's own system colors, so it reads on any design.
|
|
6
|
+
*/
|
|
7
|
+
export async function PreviewBanner({ exitPath = "/" }: { exitPath?: string }) {
|
|
8
|
+
let on = false;
|
|
9
|
+
try {
|
|
10
|
+
on = (await draftMode()).isEnabled;
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
if (!on) return null;
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
role="status"
|
|
18
|
+
style={{
|
|
19
|
+
position: "fixed",
|
|
20
|
+
insetInline: 0,
|
|
21
|
+
bottom: 16,
|
|
22
|
+
zIndex: 2147483647,
|
|
23
|
+
display: "flex",
|
|
24
|
+
justifyContent: "center",
|
|
25
|
+
pointerEvents: "none",
|
|
26
|
+
}}
|
|
27
|
+
>
|
|
28
|
+
<div
|
|
29
|
+
style={{
|
|
30
|
+
pointerEvents: "auto",
|
|
31
|
+
display: "flex",
|
|
32
|
+
alignItems: "center",
|
|
33
|
+
gap: 16,
|
|
34
|
+
padding: "10px 16px",
|
|
35
|
+
borderRadius: 999,
|
|
36
|
+
background: "CanvasText",
|
|
37
|
+
color: "Canvas",
|
|
38
|
+
font: "500 14px/1.3 system-ui, sans-serif",
|
|
39
|
+
boxShadow: "0 8px 24px color-mix(in srgb, CanvasText 25%, transparent)",
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
<span>Preview: showing unpublished changes from Site Manager</span>
|
|
43
|
+
<a href={`/api/blueprint/preview?exit=1&path=${encodeURIComponent(exitPath)}`} style={{ color: "inherit", fontWeight: 700 }}>
|
|
44
|
+
Exit preview
|
|
45
|
+
</a>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { cookies, draftMode } from "next/headers";
|
|
2
|
+
import { siteId } from "./client";
|
|
3
|
+
import { safePath, signVisualCookie, verifyPreviewToken, VISUAL_COOKIE } from "./preview";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Opens a preview of unpublished changes from Site Manager. Put in app/api/blueprint/preview/route.ts:
|
|
7
|
+
*
|
|
8
|
+
* export { GET } from "site-manager-kit/preview-route";
|
|
9
|
+
*
|
|
10
|
+
* `?token=` is a signed link from Site Manager (it lasts an hour). It turns on Next.js draft mode for this browser
|
|
11
|
+
* and opens the page; while it's on, every Site Manager read skips the cache and includes drafts and scheduled
|
|
12
|
+
* changes. `?exit=1&path=/` turns it off again. Anything else gets a 401 and changes nothing.
|
|
13
|
+
*
|
|
14
|
+
* A Visual Editor link also names the Site Manager address it was made on. It sets the visual cookie, which lets
|
|
15
|
+
* that address (and only that one) show this site in a frame while the preview lasts.
|
|
16
|
+
*/
|
|
17
|
+
export async function GET(req: Request): Promise<Response> {
|
|
18
|
+
const url = new URL(req.url);
|
|
19
|
+
const draft = await draftMode();
|
|
20
|
+
if (url.searchParams.get("exit") === "1") {
|
|
21
|
+
draft.disable();
|
|
22
|
+
(await cookies()).set({ name: VISUAL_COOKIE, value: "", ...frameCookie(url), maxAge: 0 });
|
|
23
|
+
return redirect(url, safePath(url.searchParams.get("path")) ?? "/");
|
|
24
|
+
}
|
|
25
|
+
const secret = process.env.BLUEPRINT_REVALIDATE_SECRET;
|
|
26
|
+
const ok = verifyPreviewToken(secret, url.searchParams.get("token"), siteId());
|
|
27
|
+
if (!ok) return Response.json({ error: "invalid_preview_link" }, { status: 401, headers: { "cache-control": "no-store" } });
|
|
28
|
+
draft.enable();
|
|
29
|
+
if (!ok.adminOrigin || !secret) return redirect(url, ok.path);
|
|
30
|
+
// Inside a frame on another site, cookies only stick when they are SameSite=None, Secure and, on browsers that
|
|
31
|
+
// split third-party cookies by top site, Partitioned. Next sets the draft cookie without Partitioned, so set it again.
|
|
32
|
+
const jar = await cookies();
|
|
33
|
+
const bypass = jar.get("__prerender_bypass")?.value;
|
|
34
|
+
if (bypass) jar.set({ name: "__prerender_bypass", value: bypass, httpOnly: true, ...frameCookie(url) });
|
|
35
|
+
jar.set({ name: VISUAL_COOKIE, value: signVisualCookie(secret, siteId(), ok.adminOrigin), httpOnly: true, maxAge: 3600, ...frameCookie(url) });
|
|
36
|
+
return redirect(url, ok.path, { "content-security-policy": `frame-ancestors 'self' ${ok.adminOrigin}` });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Browsers accept Secure cookies from http://localhost, so local work behaves like the live site.
|
|
40
|
+
function frameCookie(url: URL) {
|
|
41
|
+
const secure = url.protocol === "https:" || url.hostname === "localhost" || url.hostname.endsWith(".localhost");
|
|
42
|
+
return { path: "/", secure, sameSite: secure ? ("none" as const) : ("lax" as const), partitioned: secure };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Relative to this site, so a preview can never send anyone somewhere else.
|
|
46
|
+
function redirect(from: URL, path: string, headers: Record<string, string> = {}) {
|
|
47
|
+
return new Response(null, { status: 307, headers: { location: new URL(path, from.origin).toString(), "cache-control": "no-store", ...headers } });
|
|
48
|
+
}
|